From 930e39c4fc2346b98e92d6f7a8c775fea87b1645 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 18:39:17 +0000 Subject: [PATCH 01/30] Enhance internal payment handling and e2e testing setup --- .../payment/internal-payment.controller.ts | 5 + docker-compose.e2e.yaml | 2 + e2e/freight/cypress.config.ts | 2 + .../cypress/e2e/flows/export_one_time.cy.ts | 5 + e2e/freight/cypress/e2e/flows/g1-utils.ts | 49 +++++++- .../g1_s3_underfill_day_stays_open.cy.ts | 16 ++- e2e/freight/cypress/e2e/flows/import-utils.ts | 110 +++++++++++++++++- .../cypress/e2e/flows/segment_weight.cy.ts | 5 + .../cypress/fixtures/seed-import-corridor.sql | 18 ++- e2e/freight/scripts/e2e.mjs | 14 ++- 10 files changed, 210 insertions(+), 16 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index b5ff51c48..cd2816fab 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -16,6 +16,7 @@ import { BillQueryRequestDto, BillQueryResponseDto, } from "./internal-payment.dto"; +import { Public } from "@edr/api-common"; import { PaymentService } from "./payment.service"; import { BillingService } from "../billing/billing.service"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; @@ -28,6 +29,10 @@ import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") +// Service-to-service, not user-to-service: exempt from the global JwtGuard +// (there is no end-user JWT on a relay call) and authenticated instead by the +// shared service token that ServiceAuthGuard checks. +@Public() @UseGuards(ServiceAuthGuard) @Controller("internal/payments") export class InternalPaymentController { diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 20578fe56..4df2cc1e4 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -324,6 +324,8 @@ services: CYPRESS_BASE_URL: http://localhost:${E2E_BACKOFFICE_PORT:-5383} CYPRESS_API_URL: http://localhost:${E2E_API_PORT:-3101} CYPRESS_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373} + # Must match freight-api-e2e's SERVICE_AUTH_TOKEN above. + CYPRESS_SERVICE_AUTH_TOKEN: e2e-service-token volumes: - .:/repo diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts index 5abf08477..27451c049 100644 --- a/e2e/freight/cypress.config.ts +++ b/e2e/freight/cypress.config.ts @@ -42,6 +42,8 @@ export default defineConfig({ defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria", // Demo portal users: hardcoded in DemoUsersSeeder. demoPassword: "12345678", + // Shared secret for /api/internal/* — SERVICE_AUTH_TOKEN in docker-compose.e2e.yaml. + serviceAuthToken: process.env.CYPRESS_SERVICE_AUTH_TOKEN ?? "e2e-service-token", }, setupNodeEvents(on) { const dbUrl = diff --git a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts index eaba7c298..7435594e2 100644 --- a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts +++ b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts @@ -450,6 +450,11 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0 cy.task("db:query", { sql: `UPDATE freight.train_schedules SET window_opens_at = LEAST(window_opens_at, now()), + -- The e2e rules run a 1.002-minute window duration, so the + -- CREATE-time close for a departing-today schedule is already + -- in the past — hold the close out or the next 10s tick slams + -- the window shut mid-flow. + window_closes_at = GREATEST(window_closes_at, now() + interval '45 minutes'), window_phase = 'OPEN', booking_window_status = 'OPEN' WHERE id = $1 AND booking_window_status <> 'FULL'`, diff --git a/e2e/freight/cypress/e2e/flows/g1-utils.ts b/e2e/freight/cypress/e2e/flows/g1-utils.ts index c2631431d..4edc5804d 100644 --- a/e2e/freight/cypress/e2e/flows/g1-utils.ts +++ b/e2e/freight/cypress/e2e/flows/g1-utils.ts @@ -37,6 +37,7 @@ import { db, dbSchedule, forceWindowOpen, + holdPayWindows, opsStaff, ORIGIN, pollDb, @@ -190,8 +191,35 @@ export function closeWindowAndRunBatch(departure: Date) { cy.loginBackoffice(opsStaff); withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`)); - cy.contains("Doc review", { timeout: 120000 }).should("exist"); - cy.contains("button", "Doc review complete — run batch", { timeout: 120000 }).click(); + // The e2e rules run a 1-MINUTE doc review, and the login + visit above can + // outlive it — the tick then runs the batch itself and the button never + // renders. Click the button while the phase is still DOC_REVIEW; once the + // engine has advanced on its own there is nothing left to click, and the + // poll below asserts the batch ran either way. + withSchedule(departure, (s) => { + const tryRunBatch = (attempt: number): void => { + db<{ p: string }>( + `SELECT window_phase AS p FROM freight.train_schedules WHERE id = $1`, + [s.id], + ).then(({ rows }) => { + if (rows[0].p !== "DOC_REVIEW") return; // tick already ran the batch + cy.get("body").then(($body) => { + const button = $body.find( + 'button:contains("Doc review complete — run batch")', + ); + if (button.length > 0) { + cy.wrap(button.first()).click({ force: true }); + return; + } + expect(attempt, "batch board rendered its doc-review action").to.be.lessThan( + 20, + ); + cy.wait(3000, { log: false }).then(() => tryRunBatch(attempt + 1)); + }); + }); + }; + tryRunBatch(0); + }); withSchedule(departure, (s) => pollDb( @@ -199,10 +227,16 @@ export function closeWindowAndRunBatch(departure: Date) { `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, [s.id], // DONE when the batch reserved nobody — itself a scenario outcome. - (row) => !!row && ["PAYMENT", "DONE"].includes(row.window_phase as string), + // PRE_WINDOW/OPEN when an under-filled day concluded and re-opened for + // its next cycle (window duration is 1 minute in e2e). + (row) => + !!row && + ["PAYMENT", "DONE", "PRE_WINDOW", "OPEN"].includes(row.window_phase as string), 20, ), ); + // The batch stamped 1-minute pay windows; hold them while the spec pays. + holdPayWindows(); } /** @@ -224,6 +258,15 @@ export function expectBoard( cy.loginBackoffice(opsStaff); withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`)); cy.contains(/Priority Tracking/, { timeout: 120000 }).click(); + // While a window is OPEN the tab defaults to the Forecast view (an + // under-filled day re-opens for its next cycle — g1_s3's core scenario) and + // the live lanes are hidden behind the "Live state" toggle. On settled + // boards the toggle is not rendered at all, so only click it when present. + cy.contains(/Priority ranking|Live state/, { timeout: 120000 }) + .invoke("text") + .then((text) => { + if (text.includes("Live state")) cy.contains("Live state").click(); + }); cy.contains("Priority ranking", { timeout: 120000 }).should("be.visible"); if (opts.inBatch !== undefined) { diff --git a/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts index 0f925eb14..75ae1a6b3 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts @@ -91,7 +91,17 @@ describe("G1·S3: an under-filled train keeps its day open", { retries: 0 }, () configureAndOpenSchedule({ departure: DEPARTURE }); }); - it("A and B book through the API; C books 24×20FT through the portal", () => { + // The API bookings and the portal booking are SEPARATE tests on purpose. + // cy.loginPortal's cross-origin visit makes Cypress reload the runner, + // re-evaluate the bundle (re-running `before()` and regenerating the + // module-scope stamp) and restart the CURRENT test from the top. With A and + // B in the same test as the portal visit they were booked twice — once per + // pass, under two stamps, even on a freshly wiped DB — and the orphaned + // first pair expired at payment end, corrupting the board counts and the + // free-wagon arithmetic. In their own test the completed API step is never + // re-entered; the restart only repeats the login. Same structure as g1_s2, + // which is why that spec never double-booked. + it("A and B book through the API", () => { let isoSeed = 8600; (["A", "B"] as const).forEach((suffix) => { const shape = SHAPES[suffix]; @@ -105,8 +115,12 @@ describe("G1·S3: an under-filled train keeps its day open", { retries: 0 }, () }); isoSeed += shape.twenty + shape.forty; }); + }); + it("C books 24×20FT through the portal shipment form", () => { cy.loginPortal(customer); + // dbContractId picks the NEWEST *-C contract, so the duplicate seeded by + // the reload's before() pass is inert. dbContractId("C").then((contractId) => { bookContainersVisually({ contractId, diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts index 5337d9f9c..94b871412 100644 --- a/e2e/freight/cypress/e2e/flows/import-utils.ts +++ b/e2e/freight/cypress/e2e/flows/import-utils.ts @@ -640,8 +640,47 @@ export function expectClearanceOnBookingInvoice(suffix: string) { ); } +/** + * Push every still-unpaid fixture reservation's pay deadline out 10 minutes. + * + * The e2e rules run a 1-MINUTE payment window (seed-import-corridor.sql), and + * a spec's pay loop — login, settle, poll, per booking — always outlives it: + * without this the 10s tick expires the holds the spec is queued up to pay. + * Called right after the batch reserves (completeDocReview, + * closeWindowAndRunBatch), after an export FCFS accept, and again before each + * payment. Blanket over CTR-IMP-% on purpose: specs run one at a time, and the + * first payment must rescue its yet-unpaid siblings, whichever schedule they + * reserved onto. + * + * Two invariants preserved: + * - export parity ("the pay window never outlives the window close"): while a + * booking's window is still open, the extension clamps to window_closes_at; + * - expiry scenarios: specs that TEST expiry pull deadlines back into the + * past afterwards (forceReservationExpiry / forceOfferLapse), and + * endPaymentPhase now expires its schedule's unpaid holds itself — so the + * extension never masks an expiry. + */ +export function holdPayWindows() { + db( + `UPDATE freight.bookings b + SET payment_deadline = LEAST( + now() + interval '10 minutes', + COALESCE( + (SELECT ts.window_closes_at FROM freight.train_schedules ts + WHERE ts.id = b.train_schedule_id + AND ts.window_closes_at > now()), + now() + interval '10 minutes')) + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%' + AND b.deleted_at IS NULL + AND b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT') + AND b.payment_deadline IS NOT NULL`, + ); +} + /** Staff force-pay; polls PAID + SCHEDULED. */ export function markPaid(suffix: string) { + holdPayWindows(); withBooking(suffix, (b) => { apiPost(opsStaff, `/api/train-scheduling/bookings/${b.id}/mark-paid`) .its("status") @@ -668,6 +707,7 @@ export function markPaid(suffix: string) { * path that applies a pending split offer (staff mark-paid skips it). */ export function settleViaGateway(suffix: string) { + holdPayWindows(); withBooking(suffix, (b) => { db<{ intent_id: string; currency: string; total: string }>( `WITH inv AS ( @@ -698,6 +738,9 @@ export function settleViaGateway(suffix: string) { cy.request({ method: "POST", url: `${apiUrl()}/api/internal/payments/mark-paid`, + headers: { + "x-service-token": Cypress.env("serviceAuthToken") as string, + }, body: { version: 1, eventId: crypto.randomUUID(), @@ -917,6 +960,25 @@ export function resetCorridorDay(departure: Date, destCode = DEST, originCode = AND b.scheduled_date = $1::date`, [eatDayStr(departure)], ); + // Finally, soft-delete every remaining unpinned fixture booking on the day. + // Reset runs before the current run books anything, so all of them are + // prior-run debris — and merely leaving them unpinned is not enough: + // - EXPIRED ones render in the board's "Expired" lane (it lists by DAY), + // so `expired: 0` could never pass against a warm DB; + // - PAID ones sit in the day pool, and when an under-filled day re-opens + // for its next cycle the engine's batch fill re-links them to the LIVE + // schedule mid-run — observed as 15 ghosts re-pinned within one second, + // inflating "In the batch (N)" past what the spec created. + db( + `UPDATE freight.bookings b + SET deleted_at = now() + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%' + AND b.deleted_at IS NULL + AND b.train_schedule_id IS NULL + AND b.scheduled_date = $1::date`, + [eatDayStr(departure)], + ); } /** Create the reversed 6-stop corridor (ET → DJ = EXPORT) if missing. */ @@ -959,6 +1021,10 @@ export function acceptExport(suffix: string) { .should("be.oneOf", [200, 201]); }); pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 10); + // The accept stamped a 1-minute pay window (export_payment_window_minutes); + // a spec accepting several bookings would lose the first before the last is + // even accepted. Still clamped to the window close — see holdPayWindows. + holdPayWindows(); } export interface ScheduleRow { @@ -1228,18 +1294,54 @@ export function closeBookingWindow(scheduleId: string) { * (Lands on DONE instead when the batch reserved nobody.) */ export function completeDocReview(scheduleId: string) { - apiPost(opsStaff, `/api/train-scheduling/schedules/${scheduleId}/doc-review-complete`) - .its("status") - .should("be.oneOf", [200, 201]); + apiPost( + opsStaff, + `/api/train-scheduling/schedules/${scheduleId}/doc-review-complete`, + undefined, + false, + ).then((res) => { + if (res.status >= 400) { + // The e2e rules run a 1-MINUTE doc review: the tick may have run the + // batch on its own while the spec was still logging in or asserting. + // That is the engine doing the right thing on schedule — but a 4xx with + // the phase still stuck in DOC_REVIEW is a real failure. + db<{ p: string }>( + `SELECT window_phase AS p FROM freight.train_schedules WHERE id = $1`, + [scheduleId], + ).then(({ rows }) => { + expect( + rows[0]?.p, + `doc-review-complete ${res.status} — engine advanced on its own`, + ).to.be.oneOf(["PAYMENT", "DONE", "PRE_WINDOW", "OPEN"]); + }); + } + }); pollSchedulePhase( scheduleId, - ["PAYMENT", "DONE", "PRE_WINDOW"], + // OPEN: with a 1-minute window duration an under-filled day can already + // have re-opened for its next cycle by the first poll read. + ["PAYMENT", "DONE", "PRE_WINDOW", "OPEN"], `schedule ${scheduleId} payment phase`, ); + holdPayWindows(); } /** End the payment phase now — the tick settles (allocate paid / expire unpaid). */ export function endPaymentPhase(scheduleId: string) { + // holdPayWindows pushed the unpaid holds' own deadlines out so a pay loop + // could outlive the 1-minute window; ending the phase means those holds must + // now expire, so pull them back first — the settle only expires reservations + // whose OWN deadline has passed, and holds the cycle open for the rest. + db( + `UPDATE freight.bookings b + SET payment_deadline = now() - interval '1 second' + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%' + AND b.deleted_at IS NULL + AND b.train_schedule_id = $1 + AND b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`, + [scheduleId], + ); db( `UPDATE freight.train_schedules SET payment_phase_ends_at = now() - interval '1 second' diff --git a/e2e/freight/cypress/e2e/flows/segment_weight.cy.ts b/e2e/freight/cypress/e2e/flows/segment_weight.cy.ts index 16ef643dc..28068f548 100644 --- a/e2e/freight/cypress/e2e/flows/segment_weight.cy.ts +++ b/e2e/freight/cypress/e2e/flows/segment_weight.cy.ts @@ -428,6 +428,11 @@ describe( cy.task("db:query", { sql: `UPDATE freight.train_schedules SET window_opens_at = LEAST(window_opens_at, now()), + -- The e2e rules run a 1.002-minute window duration, so the + -- CREATE-time close for a departing-today schedule is already + -- in the past — hold the close out or the next 10s tick slams + -- the window shut mid-flow. + window_closes_at = GREATEST(window_closes_at, now() + interval '45 minutes'), window_phase = 'OPEN', booking_window_status = 'OPEN' WHERE id = $1 AND booking_window_status <> 'FULL'`, diff --git a/e2e/freight/cypress/fixtures/seed-import-corridor.sql b/e2e/freight/cypress/fixtures/seed-import-corridor.sql index 05d45561f..8d7aedb06 100644 --- a/e2e/freight/cypress/fixtures/seed-import-corridor.sql +++ b/e2e/freight/cypress/fixtures/seed-import-corridor.sql @@ -450,14 +450,20 @@ WHERE NOT EXISTS ( ); -- --------------------------------------------------------------------------- --- e2e window durations: 1 minute instead of the 30/60 production defaults. +-- e2e window durations — the dev-environment settings, verbatim: +-- window duration 0.0167 h (1.002 min), doc review 1 min, payment 1 min +-- (import AND export). -- --- Most specs never wait these out — closeWindowAndRunBatch clicks "Doc review --- complete" and endPaymentPhase pulls the deadline into the past — so this is --- a safety net for the paths that DO let a phase elapse on its own, not the --- main speed lever. That one is the 10s @Cron tick in booking-window.service. +-- Specs still arrange the timestamps they need (forceWindowOpen holds a +-- window open for 45 min; endPaymentPhase ends the pay phase early), but +-- every ENGINE-stamped deadline now comes from these 1-minute rules: the +-- batch's pay windows, the doc-review auto-advance, and re-opened cycles all +-- elapse in about a minute on their own via the 10s @Cron tick in +-- booking-window.service. holdPayWindows (import-utils.ts) is what keeps a +-- spec's queued-up payments from expiring under the 1-minute pay window. -- --------------------------------------------------------------------------- UPDATE freight.train_scheduling_global_rules - SET doc_review_minutes = 1, + SET window_duration_hours = 0.0167, + doc_review_minutes = 1, payment_window_minutes = 1, export_payment_window_minutes = 1; diff --git a/e2e/freight/scripts/e2e.mjs b/e2e/freight/scripts/e2e.mjs index ee59f0098..fe8d639b2 100644 --- a/e2e/freight/scripts/e2e.mjs +++ b/e2e/freight/scripts/e2e.mjs @@ -16,7 +16,7 @@ */ import { execFileSync, spawnSync } from "node:child_process"; -import { generateKeyPairSync } from "node:crypto"; +import { createHash, generateKeyPairSync } from "node:crypto"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { dirname, join, resolve } from "node:path"; @@ -25,7 +25,17 @@ import { fileURLToPath } from "node:url"; const e2eDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = resolve(e2eDir, "..", ".."); const stateFile = join(e2eDir, ".e2e-ports.json"); -const composeBase = ["compose", "-f", join(repoRoot, "docker-compose.e2e.yaml")]; +// Per-checkout compose project: parallel checkouts on one docker daemon +// otherwise share the yaml's fixed `name:` and recreate/kill each other's +// containers mid-run. +const projectName = `edr-freight-e2e-${createHash("sha1").update(repoRoot).digest("hex").slice(0, 6)}`; +const composeBase = [ + "compose", + "-p", + projectName, + "-f", + join(repoRoot, "docker-compose.e2e.yaml"), +]; const DEFAULT_PORTS = { E2E_API_PORT: 3101, From 3956c78c54919ed104bf8b0bd8943f531026a78a Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 19:17:17 +0000 Subject: [PATCH 02/30] Enhance internal payment handling and e2e testing setup --- .../contracts/GlCreateBookingForm.tsx | 4 +- .../TrainScheduleV2DetailPage.tsx | 13 +- .../src/pages/contracts/NewShipmentPage.tsx | 338 ++++++++++-------- 3 files changed, 201 insertions(+), 154 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 27e1597c5..4a1cb402f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -172,11 +172,11 @@ function emptyUnit(): UnitDraft { function emptyLine(size: string): ContainerLineDraft { return { containerSize: size, - quantity: "1", + quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [emptyUnit()], + units: [], }; } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index e0c17a57b..e89977eef 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -742,16 +742,7 @@ export default function TrainScheduleV2DetailPage() { {canEditBookings && (previewResult || displayWagonPlan.length) ? ( - {!hasContainerStep ? ( - - ) : ( + {hasContainerStep ? ( - )} + ) : null} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 50f2828f6..6b2ce4470 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -266,8 +266,7 @@ function NewShipmentBookingForm({ withReturn: contract.equipmentReturn === "WITH_RETURN", // The contract quotes USD; the customer bills this shipment in the // currency they pick here. Intercity is always ETB. - paymentCurrency: - contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD", + paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD", }, resolver: zodResolver( createShipmentFormSchema({ @@ -306,7 +305,10 @@ function NewShipmentBookingForm({ bookingId: completeBookingId, dto, }) - : api.contracts.createBookingUnderContract.call({ id: contractId, dto }), + : api.contracts.createBookingUnderContract.call({ + id: contractId, + dto, + }), onSuccess: (booking) => { queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ @@ -366,7 +368,8 @@ function NewShipmentBookingForm({ .map((l) => ({ containerSize: l.containerSize, quantity: Number(l.quantity), - hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, + hazardousQuantity: + Number(l.hazardousQuantity || 0) || undefined, reeferQuantity: Number(l.reeferQuantity || 0) || undefined, ...(withReturnService ? { returnQuantity: Number(l.returnQuantity || 0) } @@ -379,7 +382,9 @@ function NewShipmentBookingForm({ // line counts and bills each surcharge on the ticked containers. isHazardous: Boolean(u.isHazardous), isReefer: Boolean(u.isReefer), - ...(withReturnService ? { isReturn: Boolean(u.isReturn) } : {}), + ...(withReturnService + ? { isReturn: Boolean(u.isReturn) } + : {}), })), })), } @@ -417,6 +422,12 @@ function NewShipmentBookingForm({ validateMutation.mutate(buildDto(values)); }); + // The per-field messages render inline, but on a long single-page form the + // failing field is often scrolled out of view — mirror the backoffice's + // summary alert next to the submit button so the click never looks inert. + const showValidationSummary = + form.formState.isSubmitted && !form.formState.isValid; + const handleConfirm = () => { if (!pendingValues) return; // Guard: never let a booking with unresolved 20ft pairing errors submit. @@ -457,8 +468,15 @@ function NewShipmentBookingForm({ mb="lg" > - - {completeBookingId ? "Complete Your Booking" : "New Shipment Booking"} + <Title + order={1} + fw={800} + fz={26} + style={{ letterSpacing: "-0.01em" }} + > + {completeBookingId + ? "Complete Your Booking" + : "New Shipment Booking"} {completeBookingId @@ -534,28 +552,41 @@ function NewShipmentBookingForm({ marginTop: "auto", }} > - - - {/* Mantine tooltips get no pointer events from a disabled button, + + {showValidationSummary ? ( + } + mb="sm" + > + Fix the highlighted fields before reviewing the price. + + ) : null} + + + {/* Mantine tooltips get no pointer events from a disabled button, so the wrapper carries the hover target. */} - - - - - + + + + + + @@ -621,8 +652,7 @@ function PriceConfirmModal({ quantity: li.quantity, amount: li.amount, })), - total: - validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + total: validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), }; }, [validation, baseTotal]); @@ -715,8 +745,8 @@ function PriceConfirmModal({ ))} - Adjust the 20ft container weights or quantities so pairs differ - by no more than 10 tons. + Adjust the 20ft container weights or quantities so pairs + differ by no more than 10 tons. @@ -810,7 +840,12 @@ function PriceConfirmModal({ )} - + {total.lines.map((line, i) => ( @@ -1007,7 +1042,9 @@ function ScheduleStep({ const isIntercity = contract.tradeDirection === "DOMESTIC"; const { data: availableDays, isLoading } = useQuery({ ...api.bookings.getAvailableDaysForCargo.queryOptions({ - input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery), + input: + cargoQuery ?? + ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery), }), enabled: cargoQuery !== null && !isIntercity, }); @@ -1064,7 +1101,12 @@ function ScheduleStep({ title="Schedule" description="Intercity shipments have no fixed day." /> - }> + } + > Your shipment rides the next import/export train passing through your corridor. Operations assign it to a train with free capacity — you will be notified when it is accepted and payment is due. @@ -1110,7 +1152,12 @@ function ScheduleStep({ )} /> {cargoQuery === null ? ( - }> + } + > Enter your cargo details first — available shipment days depend on the wagons your cargo needs. @@ -1245,11 +1292,11 @@ function CargoStep({ "containers", sizes.map((size) => ({ containerSize: size, - quantity: "1", + quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [emptyUnit()], + units: [], })), { shouldValidate: false }, ); @@ -1289,11 +1336,11 @@ function CargoStep({ return ( current.find((l) => l.containerSize === size) ?? { containerSize: size, - quantity: "1", + quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [emptyUnit()], + units: [], } ); } @@ -1315,7 +1362,10 @@ function CargoStep({ })), }; }); - form.setValue("containers", next, { shouldValidate: true, shouldDirty: true }); + form.setValue("containers", next, { + shouldValidate: true, + shouldDirty: true, + }); setImportErrors([]); setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`); }; @@ -1330,7 +1380,12 @@ function CargoStep({ /> {sizes.length > 0 && ( - + @@ -1458,10 +1513,11 @@ function CargoStep({ title={`Odd number of 20ft containers (${ft20})`} > - 20ft containers travel two per wagon, so they must be booked in - even numbers. Please add one more 20ft container or remove one - (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — the - booking cannot be submitted with an unpaired 20ft container. + 20ft containers travel two per wagon, so they must be booked + in even numbers. Please add one more 20ft container or remove + one (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — + the booking cannot be submitted with an unpaired 20ft + container. ); @@ -1660,16 +1716,6 @@ function NotesSection({ form }: { form: ShipmentForm }) { ); } -/** A blank container row — handling switches start off. */ -const emptyUnit = () => ({ - containerNumber: "", - sealNumber: "", - vgmTons: "", - isHazardous: false, - isReefer: false, - isReturn: false, -}); - function ContainerLineEditor({ form, index, @@ -1726,7 +1772,11 @@ function ContainerLineEditor({ * price estimate and the submitted payload stay in step with the switches. */ const syncHandlingCounts = ( - units: Array<{ isHazardous?: boolean; isReefer?: boolean; isReturn?: boolean }>, + units: Array<{ + isHazardous?: boolean; + isReefer?: boolean; + isReturn?: boolean; + }>, ) => { const set = ( key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity", @@ -1856,94 +1906,100 @@ function ContainerLineEditor({ ))} )} - {Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => ( - - ( - - field.onChange(e.currentTarget.value.toUpperCase()) - } - placeholder="e.g. MSCU1234567" - error={fieldState.error?.message} - radius={10} - styles={fieldStyles} - style={{ flex: 1 }} - /> - )} - /> - ( - - )} - /> - ( - - )} - /> - {handlingColumns.map((col) => ( + {Array.from({ length: Math.max(quantity, units.length) }).map( + (_, u) => ( + ( - - - toggleUnitHandling(u, col.key, e.currentTarget.checked) - } - size="sm" - /> - + render={({ field, fieldState }) => ( + + field.onChange(e.currentTarget.value.toUpperCase()) + } + placeholder="e.g. MSCU1234567" + error={fieldState.error?.message} + radius={10} + styles={fieldStyles} + style={{ flex: 1 }} + /> )} /> - ))} - removeUnit(u)} - > - - - - ))} + ( + + )} + /> + ( + + )} + /> + {handlingColumns.map((col) => ( + ( + + + toggleUnitHandling( + u, + col.key, + e.currentTarget.checked, + ) + } + size="sm" + /> + + )} + /> + ))} + removeUnit(u)} + > + + + + ), + )} ); From f0295f401ad9a870573b642e22dea41bd7959f5e Mon Sep 17 00:00:00 2001 From: Mulu Mehari Date: Sun, 2 Aug 2026 23:08:15 +0300 Subject: [PATCH 03/30] Adding seat blocking revenue loss dashboard --- .../migration.sql | 23 + apps/edr-passenger-api/prisma/schema.prisma | 37 +- .../src/common/acting-user.ts | 63 ++ .../src/modules/dashboard/dashboard.module.ts | 9 +- .../modules/dashboard/dashboard.service.ts | 56 +- .../blocked-seats-loss.calculator.spec.ts | 641 ++++++++++++ .../reports/blocked-seats-loss.calculator.ts | 558 +++++++++++ .../src/modules/reports/reports.controller.ts | 61 +- .../src/modules/reports/reports.dto.ts | 78 +- .../src/modules/reports/reports.module.ts | 5 +- .../src/modules/reports/reports.service.ts | 412 +++++++- .../src/modules/search/search.service.ts | 36 +- .../src/modules/seats/seats.controller.ts | 30 +- .../src/modules/seats/seats.dto.ts | 40 + .../src/modules/seats/seats.service.ts | 50 +- .../test/available-dates.e2e-spec.ts | 308 ++++++ .../backoffice/src/app/dashboard/page.tsx | 73 ++ .../src/app/reports/blocked-seats/layout.tsx | 3 + .../src/app/reports/blocked-seats/page.tsx | 928 ++++++++++++++++++ .../backoffice/src/app/seats/page.tsx | 58 +- .../src/components/layout/Sidebar.tsx | 2 + .../src/lib/api/blocked-seats-loss.ts | 54 + .../backoffice/src/lib/api/dashboard.ts | 2 + .../backoffice/src/lib/chart-palette.ts | 66 ++ .../portal/src/app/booking/search/page.tsx | 87 +- .../src/components/ModernDatePicker.tsx | 20 +- .../portal/src/components/SearchWidget.tsx | 55 +- e2e-ui-report/index.html | 2 +- .../guest/search-date-availability.spec.ts | 136 +++ packages/types/src/index.ts | 4 + .../passenger/blocked-seat-revenue-loss.ts | 169 ++++ packages/types/src/passenger/index.ts | 1 + 32 files changed, 4008 insertions(+), 59 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260802000001_add_seat_block_reason_category_and_blocker_name/migration.sql create mode 100644 apps/edr-passenger-api/src/common/acting-user.ts create mode 100644 apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.spec.ts create mode 100644 apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts create mode 100644 apps/edr-passenger-api/test/available-dates.e2e-spec.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/lib/api/blocked-seats-loss.ts create mode 100644 apps/edr-passenger-web/backoffice/src/lib/chart-palette.ts create mode 100644 e2e-ui/specs/guest/search-date-availability.spec.ts create mode 100644 packages/types/src/passenger/blocked-seat-revenue-loss.ts diff --git a/apps/edr-passenger-api/prisma/migrations/20260802000001_add_seat_block_reason_category_and_blocker_name/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260802000001_add_seat_block_reason_category_and_blocker_name/migration.sql new file mode 100644 index 000000000..3cfc394e2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260802000001_add_seat_block_reason_category_and_blocker_name/migration.sql @@ -0,0 +1,23 @@ +-- Blocked Seat Revenue Loss report (GET /reports/blocked-seats-revenue-loss) needs to answer +-- "who blocked this seat and why". Purely additive: every column is nullable and every index is +-- new, so rows written before this migration keep working and simply report as +-- System / Unknown (blockedByName) and Uncategorized (reasonCategory). + +-- CreateEnum +DO $$ +BEGIN + CREATE TYPE "passenger"."SeatBlockReasonCategory" AS ENUM ('MAINTENANCE', 'VIP_RESERVED', 'SAFETY', 'OPERATIONAL', 'OTHER'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END +$$; + +-- AlterTable +ALTER TABLE "passenger"."SeatBlock" + ADD COLUMN IF NOT EXISTS "reasonCategory" "passenger"."SeatBlockReasonCategory", + ADD COLUMN IF NOT EXISTS "blockedByName" TEXT; + +-- CreateIndex: the report filters SeatBlock by blockedAt window, and joins +-- schedule-scoped blocks to a (schedule, seat) pair. +CREATE INDEX IF NOT EXISTS "SeatBlock_blockedAt_idx" ON "passenger"."SeatBlock"("blockedAt"); +CREATE INDEX IF NOT EXISTS "SeatBlock_scheduleId_seatId_idx" ON "passenger"."SeatBlock"("scheduleId", "seatId"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 00090ff1a..7bca3116d 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1349,19 +1349,38 @@ model NotificationTemplate { @@schema("passenger") } +/// Why a seat was pulled out of sale. Coarse bucket for reporting; the free-text +/// `reason` stays as the operator's detail. Nullable — rows written before this +/// column existed have no category and report as "Uncategorized". +enum SeatBlockReasonCategory { + MAINTENANCE + VIP_RESERVED + SAFETY + OPERATIONAL + OTHER + + @@schema("passenger") +} + model SeatBlock { - id String @id @default(uuid()) - seatId String - scheduleId String? - reason String - blockedBy String - approvedBy String? - blockedAt DateTime @default(now()) - unblockAt DateTime? - seat Seat @relation(fields: [seatId], references: [id]) + id String @id @default(uuid()) + seatId String + scheduleId String? + reason String + reasonCategory SeatBlockReasonCategory? + blockedBy String + /// Display name of the blocking staff member, denormalized at write time so the + /// revenue-loss report needs no cross-service IAM lookup. Null on legacy rows. + blockedByName String? + approvedBy String? + blockedAt DateTime @default(now()) + unblockAt DateTime? + seat Seat @relation(fields: [seatId], references: [id]) @@index([seatId]) @@index([scheduleId]) + @@index([blockedAt]) + @@index([scheduleId, seatId]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/common/acting-user.ts b/apps/edr-passenger-api/src/common/acting-user.ts new file mode 100644 index 000000000..630e307a8 --- /dev/null +++ b/apps/edr-passenger-api/src/common/acting-user.ts @@ -0,0 +1,63 @@ +/** + * Reading the authenticated staff member off the request. + * + * `JwtGuard` (from `@tria-plc/api-common`) puts the decoded IAM user on `request.user`. + * Sibling controllers reach for `req.user?.id ?? req.user?.sub`, because the shape differs + * slightly between token versions. This module centralises that so callers get a small, + * typed value object instead of an `any` bag. + */ + +/** Bilingual name as IAM stores it. */ +interface ActingUserName { + en?: string; + am?: string; +} + +/** The slice of `request.user` this app actually reads. */ +export interface ActingUserClaims { + id?: string; + /** Older tokens carry the subject as `sub` rather than `id`. */ + sub?: string; + name?: ActingUserName | string | null; + username?: string; + email?: string; +} + +/** The minimal Express request shape needed to reach the authenticated user. */ +export interface RequestWithActingUser { + user?: ActingUserClaims; +} + +/** Who performed an action, resolved once at write time so readers need no IAM lookup. */ +export interface ActingUser { + /** IAM user id. */ + id: string; + /** Human-readable name, denormalized alongside the id. */ + name: string; +} + +/** + * Resolves the acting staff member from a guarded request. + * + * Returns `null` when no user is attached — callers decide what that means. Endpoints behind + * `@PassengerStaff(...)` always have one, since the guard rejects anonymous requests; system + * paths (ticketing, cleanup jobs) legitimately have none and record themselves explicitly. + */ +export function resolveActingUser(req: RequestWithActingUser): ActingUser | null { + const claims = req.user; + const id = claims?.id ?? claims?.sub; + if (!id) return null; + + return { id, name: resolveActingUserName(claims) }; +} + +function resolveActingUserName(claims: ActingUserClaims | undefined): string { + if (!claims) return 'Unknown'; + const { name } = claims; + if (typeof name === 'string' && name.trim()) return name.trim(); + if (name && typeof name === 'object') { + const localized = name.en?.trim() || name.am?.trim(); + if (localized) return localized; + } + return claims.username?.trim() || claims.email?.trim() || 'Unknown'; +} diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts index 09b3717b4..5a541452c 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts @@ -1,6 +1,13 @@ import { Module } from '@nestjs/common'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; +import { ReportsModule } from '../reports/reports.module'; -@Module({ controllers: [DashboardController], providers: [DashboardService] }) +@Module({ + // ReportsModule owns the blocked-seat revenue loss rule; the dashboard's roll-up + // reads it from there instead of keeping a second copy of the definition. + imports: [ReportsModule], + controllers: [DashboardController], + providers: [DashboardService], +}) export class DashboardModule {} diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index f7e015e11..70156ded9 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -1,17 +1,34 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { BlockedSeatRevenueLossStat } from '@edr/types'; import { PrismaService } from '../../common/prisma.service'; +import { ReportsService } from '../reports/reports.service'; + +/** Window the dashboard's blocked-seat loss roll-up covers. Matches the report's default. */ +const BLOCKED_SEAT_LOSS_PERIOD_DAYS = 30; + +/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */ +const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = { + periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS, + lossByCurrency: [], + schedulesAffected: 0, + blockedSeatCount: 0, + topReasonCategory: null, +}; @Injectable() export class DashboardService { + private readonly logger = new Logger(DashboardService.name); + constructor( private prisma: PrismaService, @InjectDataSource() private dataSource: DataSource, + private reports: ReportsService, ) {} async getBackofficeStats() { - const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] = + const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows, blockedSeatRevenueLoss] = await Promise.all([ this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }), this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }), @@ -38,6 +55,9 @@ export class DashboardService { AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED') GROUP BY COALESCE("displayCurrency"::text, "currency"::text) `, + // Joined into this same call on purpose: the dashboard's request count stays + // exactly where it was, and the card renders from the payload it already fetches. + this.getBlockedSeatRevenueLossStat(), ]); const totalPackageTickets = await this.prisma.ticket.count({ @@ -58,11 +78,43 @@ export class DashboardService { totalNormalTickets: totalTickets - totalPackageTickets, totalPassengers, blockedSeatsCount, + blockedSeatRevenueLoss, revenueByCurrency: toMap(revenueRows), packageRevenueByCurrency: toMap(packageRevenueRows), }; } + /** + * Compact roll-up of the Blocked Seat Revenue Loss report over the last 30 days. + * + * Reuses the report service rather than re-deriving the rule — there is exactly one + * definition of what a blocked seat costs. A failure here degrades to zeroes instead of + * taking the whole dashboard down with it. + */ + private async getBlockedSeatRevenueLossStat(): Promise { + try { + // pageSize 1: only the summary is read, and paging does not change what it covers. + const report = await this.reports.getBlockedSeatsRevenueLoss({ page: 1, pageSize: 1 }); + const { summary } = report; + + return { + periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS, + lossByCurrency: summary.lossByCurrency, + schedulesAffected: summary.schedulesAffected, + blockedSeatCount: summary.blockedSeatCount, + // topReasonCategories is already sorted by estimated loss, descending. + topReasonCategory: summary.topReasonCategories[0]?.reasonCategory ?? null, + }; + } catch (err) { + this.logger.warn( + `Blocked-seat revenue loss roll-up unavailable — ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return EMPTY_BLOCKED_SEAT_LOSS; + } + } + async getHomeDashboard(passengerId: string) { const now = new Date(); const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([ diff --git a/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.spec.ts b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.spec.ts new file mode 100644 index 000000000..84aa5a72b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.spec.ts @@ -0,0 +1,641 @@ +import { + assembleReport, + AssembleOptions, + countSellableSeats, + LossBlock, + LossCalculatorInput, + LossCoach, + LossFare, + LossSchedule, + LossSeat, + resolveSeatClass, + selectCountedBlocks, + soldKey, +} from './blocked-seats-loss.calculator'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const DEPARTURE = new Date('2026-07-15T08:00:00.000Z'); +const NOW = new Date('2026-07-20T00:00:00.000Z'); + +const ECONOMY_LOCAL = { + id: 'sc-econ-local', + name: 'Economy', + bedPosition: null, + nationalityType: 'LOCAL', +}; +const ECONOMY_INTL = { + id: 'sc-econ-intl', + name: 'Economy (International)', + bedPosition: null, + nationalityType: 'INTERNATIONAL', +}; + +function coach(overrides: Partial = {}): LossCoach { + return { + id: 'coach-1', + number: 'C1', + coachTypeType: 'passenger', + coachTypeName: 'Economy Coach', + seatClasses: [ECONOMY_LOCAL, ECONOMY_INTL], + ...overrides, + }; +} + +function seat(overrides: Partial = {}): LossSeat { + return { + id: 'seat-1', + coachId: 'coach-1', + seatNumber: '1', + bedPosition: null, + premiumFeeMinor: 0, + ...overrides, + }; +} + +function schedule(overrides: Partial = {}): LossSchedule { + return { + id: 'sched-1', + trainNumber: 'ET-101', + routeName: 'Addis Ababa — Dire Dawa', + originStation: 'Addis Ababa', + destinationStation: 'Dire Dawa', + departureAt: DEPARTURE, + status: 'SCHEDULED', + ...overrides, + }; +} + +function block(overrides: Partial = {}): LossBlock { + return { + id: 'block-1', + seatId: 'seat-1', + scheduleId: null, + reason: 'Torn upholstery', + reasonCategory: 'MAINTENANCE', + blockedBy: 'user-1', + blockedByName: 'Abebe Bekele', + approvedBy: null, + blockedAt: new Date('2026-07-10T00:00:00.000Z'), + unblockAt: null, + ...overrides, + }; +} + +function fare(overrides: Partial = {}): LossFare { + return { + seatClassId: ECONOMY_LOCAL.id, + seatClassName: 'Economy', + farePerPassengerMinor: 50_000, // ETB 500.00 + exchangeRate: 1, + currency: 'ETB', + ...overrides, + }; +} + +/** Builds a calculator input from loose parts, wiring up the id→entity maps. */ +function makeInput(parts: { + schedules?: LossSchedule[]; + seats?: LossSeat[]; + coaches?: LossCoach[]; + /** scheduleId → coachIds assigned to it. */ + assignments?: Record; + sold?: [string, string][]; + blocks?: LossBlock[]; +}): LossCalculatorInput { + const seats = parts.seats ?? [seat()]; + const coaches = parts.coaches ?? [coach()]; + const schedules = parts.schedules ?? [schedule()]; + const assignments = parts.assignments ?? { 'sched-1': ['coach-1'] }; + + return { + schedules, + seatsById: new Map(seats.map((s) => [s.id, s])), + coachesById: new Map(coaches.map((c) => [c.id, c])), + coachIdsBySchedule: new Map( + Object.entries(assignments).map(([sid, cids]) => [sid, new Set(cids)]), + ), + soldSeatKeys: new Set((parts.sold ?? []).map(([sid, seatId]) => soldKey(sid, seatId))), + blocks: parts.blocks ?? [block()], + }; +} + +function makeOptions(overrides: Partial = {}): AssembleOptions { + return { + faresBySchedule: new Map([['sched-1', new Map([[ECONOMY_LOCAL.id, fare()]])]]), + schedulesWithoutFare: new Set(), + nationalityType: 'LOCAL', + nationalityAssumption: 'Ethiopian', + now: NOW, + dateFrom: new Date('2026-07-01T00:00:00.000Z'), + dateTo: new Date('2026-07-31T23:59:59.999Z'), + page: 1, + pageSize: 25, + sortBy: 'lossMinor', + ...overrides, + }; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('blocked-seats-loss calculator', () => { + describe('schedule attribution', () => { + it('counts a schedule-scoped block against exactly that schedule', () => { + const other = schedule({ id: 'sched-2' }); + const counted = selectCountedBlocks( + makeInput({ + schedules: [schedule(), other], + assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'] }, + blocks: [block({ scheduleId: 'sched-1' })], + }), + ); + + expect(counted.get('sched-1')).toHaveLength(1); + expect(counted.get('sched-1')?.[0].blockType).toBe('SCHEDULE'); + // Same coach runs on sched-2, but the block named sched-1 only. + expect(counted.has('sched-2')).toBe(false); + }); + + it('counts a global block against every schedule its window covers', () => { + const counted = selectCountedBlocks( + makeInput({ + schedules: [schedule(), schedule({ id: 'sched-2' })], + assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'] }, + blocks: [block({ scheduleId: null })], + }), + ); + + expect(counted.get('sched-1')?.[0].blockType).toBe('GLOBAL'); + expect(counted.get('sched-2')?.[0].blockType).toBe('GLOBAL'); + }); + + it('ignores a global block that started after departure', () => { + const counted = selectCountedBlocks( + makeInput({ + blocks: [block({ blockedAt: new Date('2026-07-16T00:00:00.000Z') })], + }), + ); + expect(counted.size).toBe(0); + }); + + it('ignores a global block that was lifted before departure', () => { + const counted = selectCountedBlocks( + makeInput({ + blocks: [ + block({ + blockedAt: new Date('2026-07-01T00:00:00.000Z'), + unblockAt: new Date('2026-07-10T00:00:00.000Z'), + }), + ], + }), + ); + expect(counted.size).toBe(0); + }); + + it('counts a global block still open at departure', () => { + const counted = selectCountedBlocks( + makeInput({ + blocks: [ + block({ + blockedAt: new Date('2026-07-01T00:00:00.000Z'), + unblockAt: new Date('2026-07-20T00:00:00.000Z'), + }), + ], + }), + ); + expect(counted.get('sched-1')).toHaveLength(1); + }); + + it('counts a seat blocked twice for one schedule only once, at the newer block', () => { + const counted = selectCountedBlocks( + makeInput({ + blocks: [ + block({ id: 'old', blockedAt: new Date('2026-07-01T00:00:00.000Z') }), + block({ id: 'new', blockedAt: new Date('2026-07-09T00:00:00.000Z') }), + ], + }), + ); + + expect(counted.get('sched-1')).toHaveLength(1); + expect(counted.get('sched-1')?.[0].block.id).toBe('new'); + }); + + it('prefers a schedule-scoped block over a global one for the same seat', () => { + const counted = selectCountedBlocks( + makeInput({ + blocks: [ + block({ id: 'global', scheduleId: null }), + block({ id: 'scoped', scheduleId: 'sched-1' }), + ], + }), + ); + + expect(counted.get('sched-1')).toHaveLength(1); + expect(counted.get('sched-1')?.[0].block.id).toBe('scoped'); + }); + + it('skips CANCELLED schedules entirely', () => { + const counted = selectCountedBlocks( + makeInput({ schedules: [schedule({ status: 'CANCELLED' })] }), + ); + expect(counted.size).toBe(0); + }); + }); + + describe('coach-assignment gating', () => { + it('ignores a global block when the seat\'s coach was not on that train', () => { + const counted = selectCountedBlocks( + makeInput({ assignments: { 'sched-1': ['coach-other'] } }), + ); + expect(counted.size).toBe(0); + }); + + it('counts a global block when the coach was assigned', () => { + const counted = selectCountedBlocks( + makeInput({ assignments: { 'sched-1': ['coach-1'] } }), + ); + expect(counted.get('sched-1')).toHaveLength(1); + }); + + it('gates each schedule independently on its own assignments', () => { + const counted = selectCountedBlocks( + makeInput({ + schedules: [schedule(), schedule({ id: 'sched-2' })], + assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-other'] }, + }), + ); + + expect(counted.has('sched-1')).toBe(true); + expect(counted.has('sched-2')).toBe(false); + }); + }); + + describe('dining and placeholder exclusion', () => { + it('excludes seats in a dining coach', () => { + const counted = selectCountedBlocks( + makeInput({ coaches: [coach({ coachTypeType: 'dining' })] }), + ); + expect(counted.size).toBe(0); + }); + + it('excludes placeholder seats whose number starts with "-"', () => { + const counted = selectCountedBlocks( + makeInput({ seats: [seat({ seatNumber: '-1' })] }), + ); + expect(counted.size).toBe(0); + }); + + // Regression: real EDR data puts a display name in CoachType.type — 'Dining Coach ' + // with a trailing space — rather than the documented 'dining' slug. An exact match + // let dining seats into the report and inflated the blocked-seat count. + it.each([ + ['dining'], + ['Dining Coach '], + ['DINING'], + [' dining '], + ])('excludes a dining coach whose type is %p', (coachTypeType) => { + const counted = selectCountedBlocks( + makeInput({ coaches: [coach({ coachTypeType, coachTypeName: 'Dining Coach ' })] }), + ); + expect(counted.size).toBe(0); + }); + + it('excludes a dining coach identified only by its coachType name', () => { + const counted = selectCountedBlocks( + makeInput({ + coaches: [coach({ coachTypeType: 'Regular Seat', coachTypeName: 'Dining Coach ' })], + }), + ); + expect(counted.size).toBe(0); + }); + + it('does not mistake a normal coach for a dining one', () => { + const counted = selectCountedBlocks( + makeInput({ + coaches: [coach({ coachTypeType: 'Regular Seat', coachTypeName: 'Hard Seat Coach' })], + }), + ); + expect(counted.get('sched-1')).toHaveLength(1); + }); + + it('keeps a real seat in a sleeper coach', () => { + const counted = selectCountedBlocks( + makeInput({ coaches: [coach({ coachTypeType: 'sleeper' })] }), + ); + expect(counted.get('sched-1')).toHaveLength(1); + }); + + it('leaves dining and placeholder seats out of the sellable-seat denominator', () => { + const input = makeInput({ + seats: [ + seat({ id: 'real-1', coachId: 'coach-1', seatNumber: '1' }), + seat({ id: 'real-2', coachId: 'coach-1', seatNumber: '2' }), + seat({ id: 'spacer', coachId: 'coach-1', seatNumber: '-1' }), + seat({ id: 'diner', coachId: 'coach-dining', seatNumber: '1' }), + ], + coaches: [coach(), coach({ id: 'coach-dining', coachTypeType: 'dining' })], + assignments: { 'sched-1': ['coach-1', 'coach-dining'] }, + }); + + expect(countSellableSeats('sched-1', input)).toBe(2); + }); + }); + + describe('blocked-after-sale exclusion', () => { + it('excludes a blocked seat that was nonetheless sold on that schedule', () => { + const counted = selectCountedBlocks( + makeInput({ sold: [['sched-1', 'seat-1']] }), + ); + expect(counted.size).toBe(0); + }); + + it('still counts the block on a schedule where the seat was not sold', () => { + const counted = selectCountedBlocks( + makeInput({ + schedules: [schedule(), schedule({ id: 'sched-2' })], + assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'] }, + sold: [['sched-1', 'seat-1']], + }), + ); + + expect(counted.has('sched-1')).toBe(false); + expect(counted.has('sched-2')).toBe(true); + }); + + it("excludes ticketing's own bookkeeping blocks", () => { + const counted = selectCountedBlocks( + makeInput({ blocks: [block({ reason: 'Booked in tickets t-1, t-2' })] }), + ); + expect(counted.size).toBe(0); + }); + }); + + describe('seat-class resolution and per-seat loss', () => { + it('picks the seat class variant matching the nationality assumption', () => { + expect(resolveSeatClass(seat(), coach(), 'LOCAL')?.id).toBe(ECONOMY_LOCAL.id); + expect(resolveSeatClass(seat(), coach(), 'INTERNATIONAL')?.id).toBe(ECONOMY_INTL.id); + }); + + it('narrows by bed position before nationality in a sleeper coach', () => { + const upper = { id: 'sc-upper', name: 'Upper Berth', bedPosition: 'upper', nationalityType: 'LOCAL' }; + const lower = { id: 'sc-lower', name: 'Lower Berth', bedPosition: 'lower', nationalityType: 'LOCAL' }; + const sleeper = coach({ seatClasses: [upper, lower] }); + + expect(resolveSeatClass(seat({ bedPosition: 'LOWER' }), sleeper, 'LOCAL')?.id).toBe('sc-lower'); + }); + + it("adds the seat's own premium fee to the class fare", () => { + const report = assembleReport( + makeInput({ seats: [seat({ premiumFeeMinor: 2_500 })] }), + selectCountedBlocks(makeInput({ seats: [seat({ premiumFeeMinor: 2_500 })] })), + makeOptions(), + ); + + // 50_000 class fare + 2_500 seat premium + expect(report.schedules[0].blocks[0].estimatedLossMinor).toBe(52_500); + }); + + it('counts the seat but claims no money when no fare could be quoted', () => { + const input = makeInput({}); + const report = assembleReport(input, selectCountedBlocks(input), makeOptions({ + faresBySchedule: new Map(), + schedulesWithoutFare: new Set(['sched-1']), + })); + + expect(report.summary.blockedSeatCount).toBe(1); + expect(report.schedules[0].estimatedLossMinor).toBe(0); + expect(report.meta.schedulesWithoutFare).toBe(1); + }); + }); + + describe('load-factor adjustment', () => { + it('scales estimated loss by sold ÷ sellable', () => { + const seats = [ + seat({ id: 'seat-1', seatNumber: '1' }), + seat({ id: 'seat-2', seatNumber: '2' }), + seat({ id: 'seat-3', seatNumber: '3' }), + seat({ id: 'seat-4', seatNumber: '4' }), + ]; + // 4 sellable seats, 2 sold ⇒ load factor 0.5. + const parts = { seats, sold: [['sched-1', 'seat-2'], ['sched-1', 'seat-3']] as [string, string][] }; + const input = makeInput(parts); + const report = assembleReport(input, selectCountedBlocks(input), makeOptions()); + + const row = report.schedules[0]; + expect(row.sellableSeats).toBe(4); + expect(row.soldSeats).toBe(2); + expect(row.loadFactorPercent).toBe(50); + expect(row.estimatedLossMinor).toBe(50_000); + expect(row.adjustedLossMinor).toBe(25_000); + }); + + it('adjusts to zero on a train that sold nothing', () => { + const input = makeInput({}); + const report = assembleReport(input, selectCountedBlocks(input), makeOptions()); + + expect(report.schedules[0].loadFactorPercent).toBe(0); + expect(report.schedules[0].estimatedLossMinor).toBe(50_000); + expect(report.schedules[0].adjustedLossMinor).toBe(0); + }); + }); + + describe('multi-currency grouping', () => { + it('groups totals per currency and never sums across them', () => { + const schedules = [schedule(), schedule({ id: 'sched-2' })]; + const seats = [ + seat({ id: 'seat-1', coachId: 'coach-1', seatNumber: '1' }), + seat({ id: 'seat-2', coachId: 'coach-2', seatNumber: '1' }), + ]; + const coaches = [coach(), coach({ id: 'coach-2', number: 'C2' })]; + const blocks = [ + block({ id: 'b1', seatId: 'seat-1', scheduleId: 'sched-1' }), + block({ id: 'b2', seatId: 'seat-2', scheduleId: 'sched-2' }), + ]; + const input = makeInput({ + schedules, + seats, + coaches, + blocks, + assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-2'] }, + }); + + const report = assembleReport( + input, + selectCountedBlocks(input), + makeOptions({ + faresBySchedule: new Map([ + ['sched-1', new Map([[ECONOMY_LOCAL.id, fare({ currency: 'ETB' })]])], + [ + 'sched-2', + new Map([ + [ECONOMY_LOCAL.id, fare({ currency: 'DJF', exchangeRate: 2, farePerPassengerMinor: 50_000 })], + ]), + ], + ]), + }), + ); + + expect(report.summary.lossByCurrency).toEqual( + expect.arrayContaining([ + { currency: 'ETB', estimatedLossMinor: 50_000, adjustedLossMinor: 0 }, + { currency: 'DJF', estimatedLossMinor: 100_000, adjustedLossMinor: 0 }, + ]), + ); + expect(report.summary.lossByCurrency).toHaveLength(2); + }); + + it('keeps reason-category and blocker breakdowns split by currency', () => { + const input = makeInput({ + schedules: [schedule(), schedule({ id: 'sched-2' })], + seats: [ + seat({ id: 'seat-1', coachId: 'coach-1', seatNumber: '1' }), + seat({ id: 'seat-2', coachId: 'coach-2', seatNumber: '1' }), + ], + coaches: [coach(), coach({ id: 'coach-2', number: 'C2' })], + blocks: [ + block({ id: 'b1', seatId: 'seat-1', scheduleId: 'sched-1' }), + block({ id: 'b2', seatId: 'seat-2', scheduleId: 'sched-2' }), + ], + assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-2'] }, + }); + + const report = assembleReport( + input, + selectCountedBlocks(input), + makeOptions({ + faresBySchedule: new Map([ + ['sched-1', new Map([[ECONOMY_LOCAL.id, fare({ currency: 'ETB' })]])], + ['sched-2', new Map([[ECONOMY_LOCAL.id, fare({ currency: 'USD' })]])], + ]), + }), + ); + + // Same category, same blocker — but two currencies, so two rows each. + expect(report.summary.topReasonCategories).toHaveLength(2); + expect(report.summary.topReasonCategories.map((r) => r.currency).sort()).toEqual(['ETB', 'USD']); + expect(report.summary.topBlockers).toHaveLength(2); + }); + }); + + describe('legacy rows', () => { + it('reports an uncategorized legacy block under UNCATEGORIZED with an Unknown blocker', () => { + const input = makeInput({ + blocks: [block({ reasonCategory: null, blockedByName: null, blockedBy: 'legacy-id' })], + }); + const report = assembleReport(input, selectCountedBlocks(input), makeOptions()); + + expect(report.schedules[0].blocks[0].reasonCategory).toBeNull(); + expect(report.summary.topReasonCategories[0].reasonCategory).toBe('UNCATEGORIZED'); + expect(report.summary.topBlockers[0].blockedByName).toBe('Unknown'); + }); + + it('names a SYSTEM blocker "System"', () => { + const input = makeInput({ + blocks: [block({ blockedBy: 'SYSTEM', blockedByName: null })], + }); + const report = assembleReport(input, selectCountedBlocks(input), makeOptions()); + + expect(report.summary.topBlockers[0].blockedByName).toBe('System'); + }); + }); + + describe('zero-blocks schedule', () => { + it('returns an empty report when nothing is blocked', () => { + const input = makeInput({ blocks: [] }); + const report = assembleReport(input, selectCountedBlocks(input), makeOptions()); + + expect(report.schedules).toHaveLength(0); + expect(report.summary.schedulesAffected).toBe(0); + expect(report.summary.blockedSeatCount).toBe(0); + expect(report.summary.lossByCurrency).toEqual([]); + expect(report.meta.total).toBe(0); + // The methodology and exclusions still travel with the (empty) answer. + expect(report.meta.exclusions.length).toBeGreaterThan(0); + expect(report.meta.methodology).toContain('counterfactual'); + }); + + it('omits unaffected schedules from a report that has other affected ones', () => { + const input = makeInput({ + schedules: [schedule(), schedule({ id: 'sched-empty' })], + assignments: { 'sched-1': ['coach-1'], 'sched-empty': ['coach-1'] }, + blocks: [block({ scheduleId: 'sched-1' })], + }); + const report = assembleReport(input, selectCountedBlocks(input), makeOptions()); + + expect(report.schedules.map((s) => s.scheduleId)).toEqual(['sched-1']); + expect(report.meta.total).toBe(1); + }); + }); + + describe('meta and drill-down detail', () => { + it('states the nationality assumption it priced at', () => { + const input = makeInput({}); + const report = assembleReport( + input, + selectCountedBlocks(input), + makeOptions({ nationalityAssumption: 'German', nationalityType: 'INTERNATIONAL' }), + ); + + expect(report.meta.nationalityAssumption).toBe('German'); + expect(report.meta.methodology).toContain('German'); + expect(report.meta.methodology).toContain('INTERNATIONAL'); + }); + + it('reports days blocked against now while a block is still open', () => { + const input = makeInput({ + blocks: [block({ blockedAt: new Date('2026-07-10T00:00:00.000Z'), unblockAt: null })], + }); + const report = assembleReport(input, selectCountedBlocks(input), makeOptions()); + + const detail = report.schedules[0].blocks[0]; + expect(detail.stillBlocked).toBe(true); + expect(detail.daysBlocked).toBe(10); // 10 Jul → 20 Jul (NOW) + }); + + it('reports days blocked against unblockAt once a block has ended', () => { + const input = makeInput({ + blocks: [ + block({ + blockedAt: new Date('2026-07-10T00:00:00.000Z'), + unblockAt: new Date('2026-07-16T00:00:00.000Z'), + }), + ], + }); + const report = assembleReport(input, selectCountedBlocks(input), makeOptions()); + + const detail = report.schedules[0].blocks[0]; + expect(detail.stillBlocked).toBe(false); + expect(detail.daysBlocked).toBe(6); + }); + + it('paginates schedules and reports the unpaginated total', () => { + const schedules = [1, 2, 3].map((n) => schedule({ id: `sched-${n}` })); + const seats = [1, 2, 3].map((n) => seat({ id: `seat-${n}`, seatNumber: String(n) })); + const blocks = [1, 2, 3].map((n) => + block({ id: `b-${n}`, seatId: `seat-${n}`, scheduleId: `sched-${n}` }), + ); + const input = makeInput({ + schedules, + seats, + blocks, + assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'], 'sched-3': ['coach-1'] }, + }); + + const report = assembleReport( + input, + selectCountedBlocks(input), + makeOptions({ + page: 2, + pageSize: 2, + faresBySchedule: new Map( + schedules.map((s) => [s.id, new Map([[ECONOMY_LOCAL.id, fare()]])]), + ), + }), + ); + + expect(report.meta.total).toBe(3); + expect(report.schedules).toHaveLength(1); + expect(report.summary.blockedSeatCount).toBe(3); // summary covers all, not the page + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts new file mode 100644 index 000000000..64c0dc87e --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts @@ -0,0 +1,558 @@ +/** + * Blocked Seat Revenue Loss — the counting rule and the money. + * + * Deliberately free of Prisma and Nest: `ReportsService` does the fetching, this module + * decides which blocks count against which schedule and what each one cost. That split is + * what makes the rule testable — every exclusion below has a unit test in + * `blocked-seats-loss.calculator.spec.ts`. + * + * All amounts are integer minor units, always carried with their currency. + */ + +import { + BlockedSeatBlockType, + BlockedSeatLossByBlocker, + BlockedSeatLossByCurrency, + BlockedSeatLossByReasonCategory, + BlockedSeatLossDetail, + BlockedSeatLossSchedule, + BlockedSeatRevenueLossReport, + SeatBlockReasonCategory, + UNCATEGORIZED_REASON_CATEGORY, +} from '@edr/types'; + +// ── Inputs ─────────────────────────────────────────────────────────────────── + +export interface LossSeatClass { + id: string; + name: string; + bedPosition: string | null; + nationalityType: string | null; +} + +export interface LossCoach { + id: string; + number: string; + /** CoachType.type — 'passenger' | 'sleeper' | 'dining' | 'baggage'. */ + coachTypeType: string; + coachTypeName: string; + seatClasses: LossSeatClass[]; +} + +export interface LossSeat { + id: string; + coachId: string; + seatNumber: string; + bedPosition: string | null; + premiumFeeMinor: number; +} + +export interface LossSchedule { + id: string; + trainNumber: string; + routeName: string | null; + originStation: string; + destinationStation: string; + departureAt: Date; + status: string; +} + +export interface LossBlock { + id: string; + seatId: string; + /** Null for a global block — one that applies wherever the seat's coach runs. */ + scheduleId: string | null; + reason: string; + reasonCategory: SeatBlockReasonCategory | null; + blockedBy: string; + blockedByName: string | null; + approvedBy: string | null; + blockedAt: Date; + unblockAt: Date | null; +} + +/** One fare quote from the fare engine, per seat class, per schedule. */ +export interface LossFare { + seatClassId: string; + seatClassName: string; + /** Base + class premium + insurance, in ETB minor units. */ + farePerPassengerMinor: number; + /** ETB → billing currency. 1 when billing in ETB. */ + exchangeRate: number; + currency: string; +} + +export interface LossCalculatorInput { + schedules: LossSchedule[]; + /** Every seat on every coach involved, keyed by seat id. */ + seatsById: Map; + /** Every coach involved, keyed by coach id. */ + coachesById: Map; + /** Coach ids assigned to each schedule, keyed by schedule id. */ + coachIdsBySchedule: Map>; + /** `${scheduleId}|${seatId}` for every seat with a CONFIRMED/BOARDED booking. */ + soldSeatKeys: Set; + /** Candidate blocks — schedule-scoped for these schedules, plus overlapping global ones. */ + blocks: LossBlock[]; +} + +/** A block that survived every gate, bound to the schedule it cost revenue on. */ +export interface CountedBlock { + block: LossBlock; + seat: LossSeat; + coach: LossCoach; + blockType: BlockedSeatBlockType; +} + +// ── Exclusions, stated once so the API can echo them verbatim ──────────────── + +export const BLOCKED_SEAT_LOSS_EXCLUSIONS: readonly string[] = [ + 'Dining-coach seats — never sold as passenger seats, so blocking one costs no fare revenue.', + 'Placeholder seats (seat number starting with "-") — layout spacers, not real seats.', + 'CANCELLED schedules — the train did not run, so no fare was lost to the block.', + 'Seats that were nonetheless sold on that schedule (a CONFIRMED or BOARDED booking exists) — blocked after sale, so no revenue was lost.', + 'System blocks created by ticket issuance ("Booked in tickets …") — bookkeeping for seats that were sold, not withheld inventory.', + 'A seat blocked more than once for the same schedule is counted once, at its most recent block.', +]; + +/** Prefix ticket issuance writes into `SeatBlock.reason` for already-sold seats. */ +export const TICKETING_BLOCK_REASON_PREFIX = 'Booked in tickets'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +// ── Step 1: which blocks count against which schedule ──────────────────────── + +/** + * Applies the counting rule. + * + * A blocked seat counts against a schedule when either: + * - a `SeatBlock` row targets that `scheduleId` directly, or + * - a global block (no `scheduleId`) was in effect at departure — `blockedAt <= + * departureAt` and (`unblockAt IS NULL` or `unblockAt >= departureAt`) — **and** the + * seat's coach was actually assigned to that schedule. + * + * …minus every exclusion in {@link BLOCKED_SEAT_LOSS_EXCLUSIONS}. + * + * Returns counted blocks keyed by schedule id. Schedules with no counted block are absent. + */ +export function selectCountedBlocks( + input: LossCalculatorInput, +): Map { + const { schedules, seatsById, coachesById, coachIdsBySchedule, soldSeatKeys, blocks } = input; + + // Per schedule, at most one counted block per seat. A schedule-scoped block beats a + // global one (it is the more specific statement); between two of the same kind, the + // most recently created wins. + const bySchedule = new Map>(); + + for (const schedule of schedules) { + if (schedule.status === 'CANCELLED') continue; + const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set(); + + for (const block of blocks) { + if (block.reason.startsWith(TICKETING_BLOCK_REASON_PREFIX)) continue; + + const seat = seatsById.get(block.seatId); + if (!seat) continue; + if (isPlaceholderSeat(seat)) continue; + + const coach = coachesById.get(seat.coachId); + if (!coach || isDiningCoach(coach)) continue; + + let blockType: BlockedSeatBlockType; + if (block.scheduleId !== null) { + if (block.scheduleId !== schedule.id) continue; + blockType = 'SCHEDULE'; + } else { + if (!assignedCoachIds.has(seat.coachId)) continue; + if (!isGlobalBlockInEffectAt(block, schedule.departureAt)) continue; + blockType = 'GLOBAL'; + } + + // Blocked but sold anyway ⇒ the fare was collected, nothing was lost. + if (soldSeatKeys.has(soldKey(schedule.id, seat.id))) continue; + + const candidate: CountedBlock = { block, seat, coach, blockType }; + const seatMap = bySchedule.get(schedule.id) ?? new Map(); + const existing = seatMap.get(seat.id); + if (!existing || supersedes(candidate, existing)) seatMap.set(seat.id, candidate); + bySchedule.set(schedule.id, seatMap); + } + } + + const result = new Map(); + for (const [scheduleId, seatMap] of bySchedule) { + if (seatMap.size === 0) continue; + result.set(scheduleId, [...seatMap.values()]); + } + return result; +} + +function supersedes(candidate: CountedBlock, existing: CountedBlock): boolean { + if (candidate.blockType !== existing.blockType) return candidate.blockType === 'SCHEDULE'; + return candidate.block.blockedAt.getTime() > existing.block.blockedAt.getTime(); +} + +function isGlobalBlockInEffectAt(block: LossBlock, departureAt: Date): boolean { + if (block.blockedAt.getTime() > departureAt.getTime()) return false; + if (block.unblockAt === null) return true; + return block.unblockAt.getTime() >= departureAt.getTime(); +} + +export function isPlaceholderSeat(seat: Pick): boolean { + return !seat.seatNumber || seat.seatNumber.startsWith('-'); +} + +/** + * `CoachType.type` is documented as a slug ('passenger' | 'sleeper' | 'dining' | 'baggage'), + * but real EDR data stores display names there instead — e.g. `'Dining Coach '`, trailing + * space included. An exact `=== 'dining'` match therefore lets dining seats through and + * inflates the blocked-seat count. Match on a substring of type *or* name so both the + * documented convention and the data as it actually exists are covered. + */ +export function isDiningCoach( + coach: Pick, +): boolean { + const haystack = `${coach.coachTypeType ?? ''} ${coach.coachTypeName ?? ''}`.toLowerCase(); + return haystack.includes('dining'); +} + +export function soldKey(scheduleId: string, seatId: string): string { + return `${scheduleId}|${seatId}`; +} + +// ── Step 2: seats that could have been sold ────────────────────────────────── + +/** + * Sellable seats on a schedule: every seat on every assigned coach, minus dining coaches + * and placeholder rows. This is the denominator of the load factor, and it deliberately + * ignores the `coachId` filter so the percentage stays comparable across filtered views. + */ +export function countSellableSeats( + scheduleId: string, + input: Pick, +): number { + const coachIds = input.coachIdsBySchedule.get(scheduleId); + if (!coachIds || coachIds.size === 0) return 0; + + let total = 0; + for (const seat of input.seatsById.values()) { + if (!coachIds.has(seat.coachId)) continue; + if (isPlaceholderSeat(seat)) continue; + const coach = input.coachesById.get(seat.coachId); + if (!coach || isDiningCoach(coach)) continue; + total++; + } + return total; +} + +// ── Step 3: the money ──────────────────────────────────────────────────────── + +/** + * Picks the seat class a seat is priced under. + * + * Bed position selects the tier in a sleeper coach; `nationalityType` then picks the + * LOCAL or INTERNATIONAL variant of that tier, matching how the fare engine resolves it. + */ +export function resolveSeatClass( + seat: LossSeat, + coach: LossCoach, + nationalityType: string, +): LossSeatClass | null { + const classes = coach.seatClasses; + if (classes.length === 0) return null; + + const bed = seat.bedPosition?.toLowerCase(); + const byBed = bed + ? classes.filter((sc) => sc.bedPosition?.toLowerCase() === bed) + : classes.filter((sc) => !sc.bedPosition); + const pool = byBed.length > 0 ? byBed : classes; + + return pool.find((sc) => sc.nationalityType === nationalityType) ?? pool[0] ?? null; +} + +/** + * What one blocked seat would have sold for: + * + * base fare + class premium + insurance (the fare engine's per-passenger fare) + * + the seat's own premium (window/berth surcharge) + * + * converted into the billing currency implied by the nationality assumption. + * + * Returns `null` when no fare could be quoted for the seat's class — the seat still + * counts as blocked, it just carries no monetary claim. + */ +export function estimateSeatLoss( + seat: LossSeat, + fare: LossFare | null, +): { estimatedLossMinor: number; currency: string } | null { + if (!fare) return null; + const etbMinor = fare.farePerPassengerMinor + (seat.premiumFeeMinor ?? 0); + return { + estimatedLossMinor: Math.round(etbMinor * fare.exchangeRate), + currency: fare.currency, + }; +} + +// ── Step 4: assemble ───────────────────────────────────────────────────────── + +export interface AssembleOptions { + /** Seat-class fares per schedule, keyed by schedule id then seat class id. */ + faresBySchedule: Map>; + /** Schedule ids whose fare calculation failed outright. */ + schedulesWithoutFare: Set; + /** 'LOCAL' or 'INTERNATIONAL' — how seat classes were resolved. */ + nationalityType: string; + /** The nationality string the fares were priced at, for `meta`. */ + nationalityAssumption: string; + /** Reference time for "days blocked" on still-blocked seats. Injected for determinism. */ + now: Date; + dateFrom: Date; + dateTo: Date; + page: number; + pageSize: number; + sortBy: string; +} + +/** + * Turns counted blocks + fares into the wire response. + * + * Schedules with no counted block are omitted: they carry no loss and no drill-down, and + * `meta.total` counts the schedules actually paginated so the two never disagree. + */ +export function assembleReport( + input: LossCalculatorInput, + countedBySchedule: Map, + options: AssembleOptions, +): BlockedSeatRevenueLossReport { + const soldCountBySchedule = countSoldSeatsPerSchedule(input.soldSeatKeys); + + const scheduleRows: BlockedSeatLossSchedule[] = []; + + for (const schedule of input.schedules) { + const counted = countedBySchedule.get(schedule.id); + if (!counted || counted.length === 0) continue; + + const fares = options.faresBySchedule.get(schedule.id) ?? new Map(); + const sellableSeats = countSellableSeats(schedule.id, input); + const soldSeats = soldCountBySchedule.get(schedule.id) ?? 0; + const loadFactor = sellableSeats > 0 ? Math.min(1, soldSeats / sellableSeats) : 0; + + const blocks: BlockedSeatLossDetail[] = counted + .map((c) => toDetail(c, fares, options)) + .sort(byCoachThenSeat); + + // One schedule prices in exactly one currency (the nationality assumption fixes it), + // so a plain sum here never crosses currencies. + const estimatedLossMinor = blocks.reduce((sum, b) => sum + b.estimatedLossMinor, 0); + const currency = blocks.find((b) => b.currency)?.currency ?? 'ETB'; + + scheduleRows.push({ + scheduleId: schedule.id, + trainNumber: schedule.trainNumber, + routeName: schedule.routeName, + originStation: schedule.originStation, + destinationStation: schedule.destinationStation, + departureAt: schedule.departureAt.toISOString(), + status: schedule.status, + sellableSeats, + soldSeats, + loadFactorPercent: +(loadFactor * 100).toFixed(1), + blockedSeatCount: blocks.length, + estimatedLossMinor, + adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor), + currency, + blocks, + }); + } + + sortSchedules(scheduleRows, options.sortBy); + + const summary = { + schedulesAffected: scheduleRows.length, + blockedSeatCount: scheduleRows.reduce((sum, s) => sum + s.blockedSeatCount, 0), + lossByCurrency: groupLossByCurrency(scheduleRows), + topReasonCategories: groupByReasonCategory(scheduleRows), + topBlockers: groupByBlocker(scheduleRows), + }; + + const page = Math.max(1, options.page); + const pageSize = Math.max(1, options.pageSize); + const paged = scheduleRows.slice((page - 1) * pageSize, page * pageSize); + + return { + summary, + schedules: paged, + meta: { + total: scheduleRows.length, + page, + pageSize, + dateFrom: options.dateFrom.toISOString(), + dateTo: options.dateTo.toISOString(), + nationalityAssumption: options.nationalityAssumption, + methodology: buildMethodology(options), + exclusions: [...BLOCKED_SEAT_LOSS_EXCLUSIONS], + schedulesWithoutFare: options.schedulesWithoutFare.size, + }, + }; +} + +function toDetail( + counted: CountedBlock, + fares: Map, + options: AssembleOptions, +): BlockedSeatLossDetail { + const { block, seat, coach, blockType } = counted; + const seatClass = resolveSeatClass(seat, coach, options.nationalityType); + const fare = lookupFare(seatClass, coach, fares); + const loss = estimateSeatLoss(seat, fare); + const endedAt = block.unblockAt ?? options.now; + + return { + blockId: block.id, + seatId: seat.id, + coachNumber: coach.number, + seatNumber: seat.seatNumber, + seatClassName: seatClass?.name ?? coach.coachTypeName ?? null, + reason: block.reason, + reasonCategory: block.reasonCategory, + blockType, + blockedBy: block.blockedBy, + blockedByName: block.blockedByName, + approvedBy: block.approvedBy, + blockedAt: block.blockedAt.toISOString(), + unblockAt: block.unblockAt ? block.unblockAt.toISOString() : null, + stillBlocked: block.unblockAt === null, + daysBlocked: Math.max( + 0, + Math.floor((endedAt.getTime() - block.blockedAt.getTime()) / MS_PER_DAY), + ), + estimatedLossMinor: loss?.estimatedLossMinor ?? 0, + currency: loss?.currency ?? 'ETB', + }; +} + +/** + * The fare engine keys its quotes by the *nationality-resolved* seat class, which may not + * be the class the seat nominally belongs to. Try the exact class, then any sibling class + * on the same coach type that was quoted. + */ +function lookupFare( + seatClass: LossSeatClass | null, + coach: LossCoach, + fares: Map, +): LossFare | null { + if (fares.size === 0) return null; + if (seatClass) { + const exact = fares.get(seatClass.id); + if (exact) return exact; + const sibling = coach.seatClasses.find( + (sc) => sc.bedPosition === seatClass.bedPosition && fares.has(sc.id), + ); + if (sibling) return fares.get(sibling.id) ?? null; + } + const anyOnCoach = coach.seatClasses.find((sc) => fares.has(sc.id)); + return anyOnCoach ? (fares.get(anyOnCoach.id) ?? null) : null; +} + +function countSoldSeatsPerSchedule(soldSeatKeys: Set): Map { + const counts = new Map(); + for (const key of soldSeatKeys) { + const scheduleId = key.slice(0, key.indexOf('|')); + counts.set(scheduleId, (counts.get(scheduleId) ?? 0) + 1); + } + return counts; +} + +function byCoachThenSeat(a: BlockedSeatLossDetail, b: BlockedSeatLossDetail): number { + const coach = (a.coachNumber ?? '').localeCompare(b.coachNumber ?? '', undefined, { + numeric: true, + }); + if (coach !== 0) return coach; + return (a.seatNumber ?? '').localeCompare(b.seatNumber ?? '', undefined, { numeric: true }); +} + +function sortSchedules(rows: BlockedSeatLossSchedule[], sortBy: string): void { + switch (sortBy) { + case 'lossMinorAsc': + rows.sort((a, b) => a.estimatedLossMinor - b.estimatedLossMinor); + break; + case 'blockedSeatCount': + rows.sort((a, b) => b.blockedSeatCount - a.blockedSeatCount); + break; + case 'departureAt': + rows.sort((a, b) => a.departureAt.localeCompare(b.departureAt)); + break; + default: + rows.sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor); + } +} + +function groupLossByCurrency(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByCurrency[] { + const byCurrency = new Map(); + for (const row of rows) { + const entry = byCurrency.get(row.currency) ?? { + currency: row.currency, + estimatedLossMinor: 0, + adjustedLossMinor: 0, + }; + entry.estimatedLossMinor += row.estimatedLossMinor; + entry.adjustedLossMinor += row.adjustedLossMinor; + byCurrency.set(row.currency, entry); + } + return [...byCurrency.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor); +} + +function groupByReasonCategory( + rows: BlockedSeatLossSchedule[], +): BlockedSeatLossByReasonCategory[] { + const groups = new Map(); + for (const row of rows) { + for (const block of row.blocks) { + const category = block.reasonCategory ?? UNCATEGORIZED_REASON_CATEGORY; + const key = `${category}|${block.currency}`; + const entry = groups.get(key) ?? { + reasonCategory: category, + count: 0, + estimatedLossMinor: 0, + currency: block.currency, + }; + entry.count++; + entry.estimatedLossMinor += block.estimatedLossMinor; + groups.set(key, entry); + } + } + return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor); +} + +function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlocker[] { + const groups = new Map(); + for (const row of rows) { + for (const block of row.blocks) { + const key = `${block.blockedBy}|${block.currency}`; + const entry = groups.get(key) ?? { + blockedBy: block.blockedBy, + // Legacy rows carry no name; 'SYSTEM' blocks are not a person. + blockedByName: + block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown'), + count: 0, + estimatedLossMinor: 0, + currency: block.currency, + }; + entry.count++; + entry.estimatedLossMinor += block.estimatedLossMinor; + groups.set(key, entry); + } + } + return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor); +} + +function buildMethodology(options: AssembleOptions): string { + return [ + 'Estimated loss is a counterfactual: it is the fare each blocked seat would have sold for, not money that left the business.', + 'Per blocked seat: estimatedLoss = base fare (distance × seat-class per-km tariff × insurance factor) + seat-class premium + insurance fee + the seat\'s own premium fee, priced for the schedule\'s full origin→destination journey.', + `Fares are priced at nationality "${options.nationalityAssumption}" (${options.nationalityType} tariff), which also fixes the billing currency. Totals are grouped per currency and never summed across them.`, + 'estimatedLossAtFullOccupancy assumes every blocked seat would have sold. adjustedLoss = estimatedLoss × load factor (sold ÷ sellable seats on that schedule), because a blocked seat on a half-empty train did not really cost a full fare. The true figure sits between the two.', + 'A blocked seat counts against a schedule when a SeatBlock names that schedule directly, or when a global block was in effect at departure and the seat\'s coach was assigned to that schedule.', + ].join(' '); +} diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 52b84da66..1eea923fd 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -1,7 +1,14 @@ -import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; +import { Body, Controller, Get, Param, Post, Query, Res } from "@nestjs/common"; +import type { Response } from "express"; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiOkResponse, + ApiProduces, +} from "@nestjs/swagger"; import { ReportsService } from "./reports.service"; -import { GenerateReportDto } from "./reports.dto"; +import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -76,6 +83,54 @@ export class ReportsController { return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort }); } + // ── Blocked Seat Revenue Loss ────────────────────────────────────────────── + + @Get("blocked-seats-revenue-loss") + @ApiOperation({ + summary: "Potential revenue lost to blocked seats, per schedule", + description: + "For every train schedule in the window, the fare revenue that could never be earned because seats were " + + "blocked out of sale — with per-seat drill-down showing who blocked each seat and why.\n\n" + + "**A blocked seat counts against a schedule when** a `SeatBlock` row names that `scheduleId` directly, " + + "**or** a global block (no `scheduleId`) was in effect at departure — `blockedAt <= departureAt` and " + + "(`unblockAt IS NULL` or `unblockAt >= departureAt`) — and the seat's coach was assigned to that schedule " + + "via `CoachAssignment`.\n\n" + + "**Excluded** (echoed in `meta.exclusions`): dining-coach seats, placeholder seats, CANCELLED schedules, " + + "seats that were sold anyway, and ticketing's own bookkeeping blocks.\n\n" + + "**This is a counterfactual.** `estimatedLossMinor` assumes every blocked seat would have sold; " + + "`adjustedLossMinor` scales it by the schedule's load factor. The real figure sits between the two — " + + "`meta.methodology` states the formula and the nationality assumption in full.\n\n" + + "All amounts are integer minor units, grouped per currency and never summed across currencies.", + }) + @ApiOkResponse({ description: "Blocked-seat revenue loss report" }) + getBlockedSeatsRevenueLoss(@Query() query: BlockedSeatsRevenueLossQueryDto) { + return this.service.getBlockedSeatsRevenueLoss(query); + } + + @Get("blocked-seats-revenue-loss/export") + @ApiOperation({ + summary: "Blocked-seat revenue loss as CSV", + description: + "Same filters as the JSON report, flattened to one row per blocked seat. Not paginated — the whole " + + "filtered result is returned.", + }) + @ApiProduces("text/csv") + @ApiOkResponse({ description: "CSV export", schema: { type: "string" } }) + // `@Res()` without passthrough so the global ResponseTransformInterceptor does not wrap + // the CSV in a `{ success, data }` envelope — same approach as the attachment stream. + async exportBlockedSeatsRevenueLoss( + @Query() query: BlockedSeatsRevenueLossQueryDto, + @Res() res: Response, + ): Promise { + const csv = await this.service.exportBlockedSeatsRevenueLossCsv(query); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="blocked-seats-revenue-loss-${new Date().toISOString().split("T")[0]}.csv"`, + ); + res.send(csv); + } + @Get(":reportId") @ApiOperation({ summary: "Get report by ID" }) getReport(@Param("reportId") reportId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts index 5fdb856ec..384e83e1f 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts @@ -1,5 +1,7 @@ -import { IsString, IsDateString, IsOptional, IsEnum } from 'class-validator'; +import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SeatBlockReasonCategory } from '../seats/seats.dto'; export enum ReportType { REVENUE = 'REVENUE', @@ -27,3 +29,77 @@ export class ExportReportDto { @ApiProperty() @IsString() reportId: string; @ApiProperty({ enum: ExportFormat }) @IsEnum(ExportFormat) format: ExportFormat; } + +// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────── + +export enum BlockedSeatsLossSortBy { + /** Largest estimated loss first (default). */ + LOSS_DESC = 'lossMinor', + /** Smallest estimated loss first. */ + LOSS_ASC = 'lossMinorAsc', + /** Most blocked seats first. */ + BLOCKED_SEATS = 'blockedSeatCount', + /** Soonest departure first. */ + DEPARTURE = 'departureAt', +} + +export class BlockedSeatsRevenueLossQueryDto { + @ApiPropertyOptional({ + example: '2026-07-01', + description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to 30 days ago.', + }) + @IsOptional() @IsDateString() dateFrom?: string; + + @ApiPropertyOptional({ + example: '2026-07-31', + description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to today.', + }) + @IsOptional() @IsDateString() dateTo?: string; + + @ApiPropertyOptional({ description: 'Restrict to a single TrainSchedule.' }) + @IsOptional() @IsString() scheduleId?: string; + + @ApiPropertyOptional({ description: 'Restrict to schedules running this route.' }) + @IsOptional() @IsString() routeId?: string; + + @ApiPropertyOptional({ description: 'Restrict to schedules operated by this train.' }) + @IsOptional() @IsString() trainId?: string; + + @ApiPropertyOptional({ + description: + 'Restrict to blocks on seats in this coach. Load factor still reflects the whole train, so the percentage stays comparable.', + }) + @IsOptional() @IsString() coachId?: string; + + @ApiPropertyOptional({ + enum: SeatBlockReasonCategory, + description: 'Restrict to blocks in this reporting bucket. Legacy uncategorized blocks are excluded when set.', + }) + @IsOptional() @IsEnum(SeatBlockReasonCategory) reasonCategory?: SeatBlockReasonCategory; + + @ApiPropertyOptional({ + description: "Blocker filter — matches the IAM user id exactly, or the recorded name case-insensitively.", + }) + @IsOptional() @IsString() blockedBy?: string; + + @ApiPropertyOptional({ + example: 'Ethiopian', + default: 'Ethiopian', + description: + 'Nationality the counterfactual fares are priced at. Drives both the seat-class tariff variant (LOCAL vs INTERNATIONAL) and the billing currency. Defaults to Ethiopian — the local tariff in ETB.', + }) + @IsOptional() @IsString() nationality?: string; + + @ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page of schedules, 1-based.' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number; + + @ApiPropertyOptional({ default: 25, minimum: 1, maximum: 200, description: 'Schedules per page.' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number; + + @ApiPropertyOptional({ + enum: BlockedSeatsLossSortBy, + default: BlockedSeatsLossSortBy.LOSS_DESC, + description: 'Schedule ordering. Defaults to largest estimated loss first.', + }) + @IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy; +} diff --git a/apps/edr-passenger-api/src/modules/reports/reports.module.ts b/apps/edr-passenger-api/src/modules/reports/reports.module.ts index 801120cbf..5e4de0c18 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.module.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.module.ts @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { ReportsController } from './reports.controller'; import { ReportsService } from './reports.service'; +import { FareEngineModule } from '../fare-engine/fare-engine.module'; @Module({ - imports: [HttpModule], + // FareEngineModule supplies the counterfactual fares the blocked-seat revenue + // loss report prices blocked seats against — never re-implemented here. + imports: [HttpModule, FareEngineModule], controllers: [ReportsController], providers: [ReportsService], exports: [ReportsService] diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 49be0859c..5c4112773 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -1,8 +1,105 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; +import { + BlockedSeatRevenueLossReport, + UNCATEGORIZED_REASON_CATEGORY, +} from "@edr/types"; import { PrismaService } from "../../common/prisma.service"; -import { GenerateReportDto, ReportType } from "./reports.dto"; +import { FareEngineService } from "../fare-engine/fare-engine.service"; +import { + BlockedSeatsLossSortBy, + BlockedSeatsRevenueLossQueryDto, + GenerateReportDto, + ReportType, +} from "./reports.dto"; +import { + assembleReport, + LossCalculatorInput, + LossCoach, + LossFare, + LossSeat, + selectCountedBlocks, + soldKey, +} from "./blocked-seats-loss.calculator"; + +/** Fares are quoted at the local tariff unless the caller asks otherwise. */ +const DEFAULT_LOSS_NATIONALITY = "Ethiopian"; +/** Window used when the caller supplies neither `dateFrom` nor `dateTo`. */ +const DEFAULT_LOSS_WINDOW_DAYS = 30; +const DEFAULT_LOSS_PAGE_SIZE = 25; +/** How many schedules are priced in parallel. Keeps the DB from being flooded. */ +const FARE_QUOTE_CONCURRENCY = 4; +/** CSV export is not paginated, but still needs an upper bound. */ +const CSV_EXPORT_MAX_SCHEDULES = 5000; + +const EMPTY_LOSS_INPUT: LossCalculatorInput = { + schedules: [], + seatsById: new Map(), + coachesById: new Map(), + coachIdsBySchedule: new Map(), + soldSeatKeys: new Set(), + blocks: [], +}; + +/** + * Resolves the reporting window. Both bounds are inclusive and snap to whole local days, + * matching `generateReport`. Defaults to the last 30 days of departures. + */ +function resolveWindow( + query: Pick, + now: Date, +): { dateFrom: Date; dateTo: Date } { + const dateTo = query.dateTo ? new Date(query.dateTo) : new Date(now); + dateTo.setHours(23, 59, 59, 999); + + const dateFrom = query.dateFrom + ? new Date(query.dateFrom) + : new Date(dateTo.getTime() - DEFAULT_LOSS_WINDOW_DAYS * 24 * 60 * 60 * 1000); + dateFrom.setHours(0, 0, 0, 0); + + return { dateFrom, dateTo }; +} + +/** Mirrors the fare engine's own LOCAL/INTERNATIONAL split. */ +function resolveNationalityType(nationality: string): string { + const upper = nationality.toUpperCase(); + return upper === "ETHIOPIAN" || upper === "DJIBOUTIAN" ? "LOCAL" : "INTERNATIONAL"; +} + +/** + * The fare engine returns two shapes: a full distance-based calculation, and a thinner + * FareRule fallback for schedules with no route. Both are reduced to the fields the loss + * calculator needs, or dropped if neither shape is present. + */ +function normalizeFareQuote(quote: unknown): LossFare | null { + if (typeof quote !== "object" || quote === null) return null; + const q = quote as Record; + + const seatClassId = q.seatClassId; + if (typeof seatClassId !== "string") return null; + + const fareMinor = + typeof q.farePerPassengerMinor === "number" + ? q.farePerPassengerMinor + : typeof q.totalMinor === "number" + ? q.totalMinor + : null; + if (fareMinor === null) return null; + + return { + seatClassId, + seatClassName: typeof q.seatClassName === "string" ? q.seatClassName : "Unknown", + farePerPassengerMinor: fareMinor, + exchangeRate: typeof q.exchangeRate === "number" ? q.exchangeRate : 1, + currency: typeof q.billingCurrency === "string" ? q.billingCurrency : "ETB", + }; +} + +/** RFC 4180 cell: always quoted, embedded quotes doubled. */ +function toCsvCell(value: string | number): string { + return `"${String(value).replace(/"/g, '""')}"`; +} @Injectable() export class ReportsService { @@ -10,6 +107,7 @@ export class ReportsService { constructor( private prisma: PrismaService, @InjectDataSource() private dataSource: DataSource, + private fareEngine: FareEngineService, ) {} async generateReport(dto: GenerateReportDto) { @@ -1135,6 +1233,318 @@ export class ReportsService { }; } + // ── Blocked Seat Revenue Loss ────────────────────────────────────────────── + + /** + * Potential revenue lost to seats that were blocked and therefore never sellable. + * + * The counting rule and the money live in `blocked-seats-loss.calculator.ts`; this method + * is the fetch plan. Query count is bounded and independent of the number of schedules: + * schedules → coach assignments → seats → booking seats → seat blocks, plus one fare + * calculation per *affected* schedule (schedules with no blocked seat need no fare). + */ + async getBlockedSeatsRevenueLoss( + query: BlockedSeatsRevenueLossQueryDto, + ): Promise { + const now = new Date(); + const { dateFrom, dateTo } = resolveWindow(query, now); + const nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY; + const nationalityType = resolveNationalityType(nationalityAssumption); + + // 1 — schedules in the window. CANCELLED trains never ran, so nothing was lost on them. + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: dateFrom, lte: dateTo }, + status: { not: 'CANCELLED' }, + ...(query.scheduleId ? { id: query.scheduleId } : {}), + ...(query.routeId ? { routeId: query.routeId } : {}), + ...(query.trainId ? { trainId: query.trainId } : {}), + }, + select: { + id: true, + departureAt: true, + status: true, + train: { select: { number: true } }, + route: { select: { name: true } }, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + orderBy: { departureAt: 'desc' }, + }); + + const emptyOptions = { + faresBySchedule: new Map>(), + schedulesWithoutFare: new Set(), + nationalityType, + nationalityAssumption, + now, + dateFrom, + dateTo, + page: query.page ?? 1, + pageSize: query.pageSize ?? DEFAULT_LOSS_PAGE_SIZE, + sortBy: query.sortBy ?? BlockedSeatsLossSortBy.LOSS_DESC, + }; + + if (schedules.length === 0) { + return assembleReport(EMPTY_LOSS_INPUT, new Map(), emptyOptions); + } + + const scheduleIds = schedules.map((s) => s.id); + const departures = schedules.map((s) => s.departureAt.getTime()); + const earliestDeparture = new Date(Math.min(...departures)); + const latestDeparture = new Date(Math.max(...departures)); + + // 2 — coach assignments. Unfiltered by `coachId` on purpose: the load factor must + // describe the whole train even when the block list is narrowed to one coach. + const assignments = await this.prisma.coachAssignment.findMany({ + where: { scheduleId: { in: scheduleIds } }, + select: { + scheduleId: true, + coachId: true, + coach: { + select: { + id: true, + number: true, + coachType: { + select: { + name: true, + type: true, + seatClasses: { + select: { id: true, name: true, bedPosition: true, nationalityType: true }, + }, + }, + }, + }, + }, + }, + }); + + const coachesById = new Map(); + const coachIdsBySchedule = new Map>(); + for (const assignment of assignments) { + const coachIds = coachIdsBySchedule.get(assignment.scheduleId) ?? new Set(); + coachIds.add(assignment.coachId); + coachIdsBySchedule.set(assignment.scheduleId, coachIds); + + if (!coachesById.has(assignment.coachId)) { + coachesById.set(assignment.coachId, { + id: assignment.coach.id, + number: assignment.coach.number, + coachTypeType: assignment.coach.coachType?.type ?? 'passenger', + coachTypeName: assignment.coach.coachType?.name ?? 'Unknown', + seatClasses: assignment.coach.coachType?.seatClasses ?? [], + }); + } + } + + // 3 — seats on those coaches. Bounded by fleet size, not by schedule count. + const coachIds = [...coachesById.keys()]; + const seatRows = coachIds.length + ? await this.prisma.seat.findMany({ + where: { coachId: { in: coachIds } }, + select: { + id: true, + coachId: true, + seatNumber: true, + bedPosition: true, + premiumFeeMinor: true, + }, + }) + : []; + const seatsById = new Map(seatRows.map((s) => [s.id, s])); + + // 4 — seats actually sold on these schedules. Same tri-branch shape the other + // schedule reports use: outbound leg, return leg, and legacy rows with a null + // scheduleId that inherit the booking's schedule. + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + OR: [ + { scheduleId: { in: scheduleIds } }, + { leg: 2, booking: { returnScheduleId: { in: scheduleIds } } }, + { scheduleId: null, leg: 1, booking: { scheduleId: { in: scheduleIds } } }, + ], + }, + select: { + seatId: true, + scheduleId: true, + leg: true, + booking: { select: { scheduleId: true, returnScheduleId: true } }, + }, + }); + + const scheduleIdSet = new Set(scheduleIds); + const soldSeatKeys = new Set(); + for (const bs of bookingSeats) { + const effectiveScheduleId = + bs.scheduleId ?? (bs.leg === 2 ? bs.booking.returnScheduleId : bs.booking.scheduleId); + if (!effectiveScheduleId || !scheduleIdSet.has(effectiveScheduleId)) continue; + soldSeatKeys.add(soldKey(effectiveScheduleId, bs.seatId)); + } + + // 5 — candidate blocks: schedule-scoped ones for these schedules, plus global ones + // whose active window overlaps the departure range at all. Per-schedule precision + // is applied in the calculator against each schedule's own departureAt. + const blockRows = await this.prisma.seatBlock.findMany({ + where: { + AND: [ + { + OR: [ + { scheduleId: { in: scheduleIds } }, + { + scheduleId: null, + blockedAt: { lte: latestDeparture }, + OR: [{ unblockAt: null }, { unblockAt: { gte: earliestDeparture } }], + }, + ], + }, + ...(query.reasonCategory ? [{ reasonCategory: query.reasonCategory }] : []), + ...(query.coachId ? [{ seat: { coachId: query.coachId } }] : []), + ...(query.blockedBy + ? [ + { + OR: [ + { blockedBy: query.blockedBy }, + { + blockedByName: { + contains: query.blockedBy, + mode: 'insensitive' as const, + }, + }, + ], + }, + ] + : []), + ], + }, + select: { + id: true, + seatId: true, + scheduleId: true, + reason: true, + reasonCategory: true, + blockedBy: true, + blockedByName: true, + approvedBy: true, + blockedAt: true, + unblockAt: true, + }, + orderBy: { blockedAt: 'desc' }, + }); + + const input: LossCalculatorInput = { + schedules: schedules.map((s) => ({ + id: s.id, + trainNumber: s.train?.number ?? '—', + routeName: s.route?.name ?? null, + originStation: s.originStation?.name ?? '—', + destinationStation: s.destinationStation?.name ?? '—', + departureAt: s.departureAt, + status: s.status, + })), + seatsById, + coachesById, + coachIdsBySchedule, + soldSeatKeys, + blocks: blockRows, + }; + + const countedBySchedule = selectCountedBlocks(input); + + // 6 — one fare calculation per affected schedule, never per seat. + const { faresBySchedule, schedulesWithoutFare } = await this.quoteFaresForSchedules( + [...countedBySchedule.keys()], + nationalityAssumption, + ); + + return assembleReport(input, countedBySchedule, { + ...emptyOptions, + faresBySchedule, + schedulesWithoutFare, + }); + } + + /** + * Quotes every active seat class on each affected schedule, in small concurrent batches + * so a wide date range does not open hundreds of simultaneous fare calculations. + */ + private async quoteFaresForSchedules( + scheduleIds: string[], + nationality: string, + ): Promise<{ + faresBySchedule: Map>; + schedulesWithoutFare: Set; + }> { + const faresBySchedule = new Map>(); + const schedulesWithoutFare = new Set(); + + for (let i = 0; i < scheduleIds.length; i += FARE_QUOTE_CONCURRENCY) { + const batch = scheduleIds.slice(i, i + FARE_QUOTE_CONCURRENCY); + await Promise.all( + batch.map(async (scheduleId) => { + try { + const quotes = await this.fareEngine.calculateAllForSchedule(scheduleId, nationality); + const bySeatClass = new Map(); + for (const quote of quotes) { + const fare = normalizeFareQuote(quote); + if (fare) bySeatClass.set(fare.seatClassId, fare); + } + if (bySeatClass.size === 0) { + schedulesWithoutFare.add(scheduleId); + return; + } + faresBySchedule.set(scheduleId, bySeatClass); + } catch (err) { + // A schedule with no route and no fare rules cannot be priced. Its blocked + // seats still show up in the report; they just carry no monetary claim. + this.logger.warn( + `Blocked-seat loss: no fare for schedule ${scheduleId} — ${ + err instanceof Error ? err.message : String(err) + }`, + ); + schedulesWithoutFare.add(scheduleId); + } + }), + ); + } + + return { faresBySchedule, schedulesWithoutFare }; + } + + /** CSV of the same report, one row per blocked seat, honouring the same filters. */ + async exportBlockedSeatsRevenueLossCsv( + query: BlockedSeatsRevenueLossQueryDto, + ): Promise { + // Export is the whole filtered result, not the caller's page. + const report = await this.getBlockedSeatsRevenueLoss({ + ...query, + page: 1, + pageSize: CSV_EXPORT_MAX_SCHEDULES, + }); + + const headers = [ + 'Train', 'Route', 'Origin', 'Destination', 'Departure', 'Schedule Status', + 'Sellable Seats', 'Sold Seats', 'Load Factor %', 'Coach', 'Seat', 'Seat Class', + 'Block Type', 'Reason Category', 'Reason', 'Blocked By', 'Blocked By Name', + 'Approved By', 'Blocked At', 'Unblock At', 'Still Blocked', 'Days Blocked', + 'Estimated Loss (minor)', 'Currency', + ]; + + const rows = report.schedules.flatMap((s) => + s.blocks.map((b) => [ + s.trainNumber, s.routeName ?? '', s.originStation, s.destinationStation, + s.departureAt, s.status, s.sellableSeats, s.soldSeats, s.loadFactorPercent, + b.coachNumber ?? '', b.seatNumber ?? '', b.seatClassName ?? '', + b.blockType, b.reasonCategory ?? UNCATEGORIZED_REASON_CATEGORY, b.reason, + b.blockedBy, b.blockedByName ?? '', b.approvedBy ?? '', + b.blockedAt, b.unblockAt ?? '', b.stillBlocked ? 'YES' : 'NO', b.daysBlocked, + b.estimatedLossMinor, b.currency, + ]), + ); + + return [headers, ...rows].map((row) => row.map(toCsvCell).join(',')).join('\n'); + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId }, diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 8820619d8..262ddd3a6 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -442,13 +442,47 @@ export class SearchService { where: { active: true, stops: { some: { stationId: originStationId } } }, select: { stops: { select: { stationId: true, sequence: true } } }, }); - return candidateRoutes.some((r) => { + const onRouteDefinition = candidateRoutes.some((r) => { const o = r.stops.find((s) => s.stationId === originStationId); const d = r.stops.find((s) => s.stationId === destinationStationId); return !!o && !!d && o.sequence < d.sequence; }); + if (onRouteDefinition) return true; + + // Fallback: a schedule whose own stop times connect the pair in order. + // + // A return leg is modelled by reusing the outbound Route while laying its TripStopTimes + // in the opposite order (see test/fixtures/seed-ui.ts). The RouteStop check above cannot + // see that — it only knows A→B→C — so it reports "no route" for C→A even though + // searchSchedules finds and sells that trip, because searchSchedules resolves + // connectivity from TripStopTime.sequence, exactly like the availability loop below. + // Without this fallback the endpoint contradicts the search it is meant to preview, and + // the portal would disable the date picker for a pair that is genuinely bookable. + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + AND: [ + { stopTimes: { some: { stationId: originStationId } } }, + { stopTimes: { some: { stationId: destinationStationId } } }, + ], + }, + select: { + stopTimes: { + where: { stationId: { in: [originStationId, destinationStationId] } }, + select: { stationId: true, sequence: true }, + }, + }, + take: this.ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT, + }); + return schedules.some((s) => { + const o = s.stopTimes.find((st) => st.stationId === originStationId); + const d = s.stopTimes.find((st) => st.stationId === destinationStationId); + return !!o && !!d && o.sequence < d.sequence; + }); } + /** Bounds the stop-time fallback scan — connectivity is a yes/no, not a survey. */ + private readonly ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT = 200; + /** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */ private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean { return ( diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index c66dca06d..d19b6f024 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -7,6 +7,7 @@ import { Post, Patch, Query, + Req, SetMetadata, UseGuards, } from "@nestjs/common"; @@ -20,7 +21,8 @@ import { ApiBody, } from "@nestjs/swagger"; import { SeatsService } from "./seats.service"; -import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto"; +import { BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto"; +import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user"; import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; import { PassengerStaff } from "../../common/passenger-guards"; @@ -220,11 +222,22 @@ This makes it clear which segment of the route each seat is held for, enabling s @Post(":seatId/block") @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") - @ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" }) + @ApiOperation({ + summary: "Block a seat (e.g., maintenance, damage)", + description: + "The authenticated staff member is recorded as the blocker — their IAM id in `blockedBy` and their " + + "display name in `blockedByName` — so the Blocked Seat Revenue Loss report can attribute the block " + + "without a cross-service lookup.", + }) @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiBody({ type: BlockSeatDto }) @ApiResponse({ status: 200, description: "Seat blocked" }) - blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string; scheduleId?: string }) { - return this.service.blockSeat(seatId, body.reason, body.scheduleId); + blockSeat( + @Param("seatId") seatId: string, + @Body() body: BlockSeatDto, + @Req() req: RequestWithActingUser, + ) { + return this.service.blockSeat(seatId, body, resolveActingUser(req)); } @Delete(":seatId/block") @@ -243,9 +256,14 @@ This makes it clear which segment of the route each seat is held for, enabling s @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Set seat status to Under Maintenance" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiBody({ type: SetMaintenanceDto }) @ApiResponse({ status: 200, description: "Seat set to under maintenance" }) - setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) { - return this.service.setMaintenance(seatId, body.reason); + setMaintenance( + @Param("seatId") seatId: string, + @Body() body: SetMaintenanceDto, + @Req() req: RequestWithActingUser, + ) { + return this.service.setMaintenance(seatId, body.reason, resolveActingUser(req)); } @Delete(":seatId/maintenance") diff --git a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts index 87d47588e..1d177e719 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts @@ -53,3 +53,43 @@ export class ReleaseHoldDto { @ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' }) @IsString() holdId: string; } + +/** Coarse bucket for *why* a seat was pulled out of sale — mirrors the Prisma + * `SeatBlockReasonCategory` enum. The free-text `reason` stays the detail. */ +export enum SeatBlockReasonCategory { + MAINTENANCE = 'MAINTENANCE', + VIP_RESERVED = 'VIP_RESERVED', + SAFETY = 'SAFETY', + OPERATIONAL = 'OPERATIONAL', + OTHER = 'OTHER', +} + +export class BlockSeatDto { + @ApiProperty({ + example: 'Torn upholstery — awaiting replacement', + description: 'Free-text detail explaining the block. Shown verbatim in the revenue-loss report.', + }) + @IsString() reason: string; + + @ApiPropertyOptional({ + example: 'schedule-uuid', + description: + 'When set, the block applies only to this schedule. Omit for a global block that pulls the seat out of sale on every schedule its coach runs on.', + }) + @IsOptional() @IsString() scheduleId?: string; + + @ApiPropertyOptional({ + enum: SeatBlockReasonCategory, + default: SeatBlockReasonCategory.OTHER, + description: + 'Reporting bucket for this block. Defaults to OTHER. Drives the reason-category breakdown in the Blocked Seat Revenue Loss report.', + }) + @IsOptional() + @IsEnum(SeatBlockReasonCategory) + reasonCategory?: SeatBlockReasonCategory; +} + +export class SetMaintenanceDto { + @ApiProperty({ example: 'Seat recline mechanism jammed' }) + @IsString() reason: string; +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 77e13740a..383f7d96c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -1,6 +1,7 @@ import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { HoldSeatsDto, JourneyDirection } from './seats.dto'; +import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto'; +import { ActingUser } from '../../common/acting-user'; import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; @@ -696,7 +697,10 @@ export class SeatsService { coachNumber: b.seat.coach.number, scheduleId: b.scheduleId, reason: b.reason, + // Blocks written before the reason-category column existed report as uncategorized. + reasonCategory: b.reasonCategory, blockedBy: b.blockedBy, + blockedByName: b.blockedByName, blockedAt: b.blockedAt, unblockAt: b.unblockAt, })); @@ -898,20 +902,42 @@ export class SeatsService { return { imported, errors: errors.slice(0, 10) }; } - async blockSeat(seatId: string, reason: string, scheduleId?: string) { + /** + * Pulls a seat out of sale. + * + * `actor` is the authenticated staff member from the request. Their IAM id lands in + * `blockedBy` and their display name is denormalized into `blockedByName`, so the + * Blocked Seat Revenue Loss report can attribute the block without a cross-service + * lookup. System-initiated blocks (no authenticated user) fall back to `SYSTEM`. + */ + async blockSeat(seatId: string, dto: BlockSeatDto, actor: ActingUser | null) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); + const { reason, scheduleId } = dto; + const reasonCategory = dto.reasonCategory ?? SeatBlockReasonCategory.OTHER; + const blockedBy = actor?.id ?? 'SYSTEM'; + const blockedByName = actor?.name ?? 'System'; + // Schedule-scoped block: only affects this schedule, not all schedules // Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules if (scheduleId) { - await this.prisma.seatBlock.create({ data: { seatId, scheduleId, reason, blockedBy: 'system' } }); + await this.prisma.seatBlock.create({ + data: { seatId, scheduleId, reason, reasonCategory, blockedBy, blockedByName }, + }); } else { await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); - await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); + await this.prisma.seatBlock.create({ + data: { seatId, reason, reasonCategory, blockedBy, blockedByName }, + }); } - await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason, scheduleId } }); - return { blocked: true, seatId, reason, scheduleId }; + await this.auditService.log({ + action: 'UPDATE', + entityType: 'Seat', + entityId: seatId, + newData: { status: 'BLOCKED', reason, reasonCategory, scheduleId, blockedBy }, + }); + return { blocked: true, seatId, reason, reasonCategory, scheduleId, blockedBy, blockedByName }; } async unblockSeat(seatId: string, scheduleId?: string) { @@ -928,12 +954,20 @@ export class SeatsService { return { unblocked: true, seatId, scheduleId }; } - async setMaintenance(seatId: string, reason: string) { + async setMaintenance(seatId: string, reason: string, actor: ActingUser | null) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance'); await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } }); - await this.prisma.seatBlock.create({ data: { seatId, reason: `MAINTENANCE: ${reason}`, blockedBy: 'system' } }); + await this.prisma.seatBlock.create({ + data: { + seatId, + reason: `MAINTENANCE: ${reason}`, + reasonCategory: SeatBlockReasonCategory.MAINTENANCE, + blockedBy: actor?.id ?? 'SYSTEM', + blockedByName: actor?.name ?? 'System', + }, + }); return { maintenance: true, seatId, reason }; } diff --git a/apps/edr-passenger-api/test/available-dates.e2e-spec.ts b/apps/edr-passenger-api/test/available-dates.e2e-spec.ts new file mode 100644 index 000000000..bd6ed4363 --- /dev/null +++ b/apps/edr-passenger-api/test/available-dates.e2e-spec.ts @@ -0,0 +1,308 @@ +/** + * `/search/available-dates` — the data behind the search form's date picker. + * + * The portal disables the date control entirely when `routeExists` is false, and grays out + * individual days that report `available: false`. Both behaviours are only as correct as this + * endpoint, so this suite pins: + * + * 1. routeExists=false (with an empty `dates` array) for a station pair no active route + * connects in that direction — the case that disables the whole control. + * 2. routeExists=true plus a per-date availability list when a route does connect them. + * 3. Direction matters: seed-core's route runs A→B→C, so C→A is NOT a route even though + * both stations sit on it. This is the exact regression the UI relies on — a reverse + * pair must not be treated as bookable. + * 4. A date only counts as available when a bookable schedule actually departs that day. + * 5. The range is clamped server-side and never reports dates in the past. + * + * Uses the slim harness (real Nest DI) for schedule creation so the real interpolation runs, + * then instantiates SearchService directly with a real Prisma — SearchModule is not in the + * slim harness's DOMAIN_MODULES (it pulls in NotificationsModule → RabbitMQ), mirroring the + * Tier-2 pattern in stop-based-booking-segment.e2e-spec.ts. + */ +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SearchService } from "../src/modules/search/search.service"; +import { SegmentsService } from "../src/modules/segments/segments.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; + +const ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** Calendar date in Africa/Addis_Ababa (fixed UTC+3) — matches the service's own conversion. */ +function addisDateStr(d: Date): string { + return new Date(d.getTime() + ADDIS_OFFSET_MS).toISOString().slice(0, 10); +} + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * ONE_DAY_MS); +} + +describe("GET /search/available-dates", () => { + let harness: ServiceHarness; + let searchService: SearchService; + let schedulesService: SchedulesService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const currencyService = harness.moduleRef.get(CurrencyService); + const fareEngine = harness.moduleRef.get(FareEngineService); + const segmentsService = new SegmentsService(harness.prisma as any); + searchService = new SearchService( + harness.prisma as any, + currencyService, + fareEngine, + segmentsService, + ); + }); + + afterAll(async () => { + await harness.close(); + }); + + beforeEach(async () => { + await resetAndSeedCore(harness.prisma); + }); + + /** Creates a bookable schedule departing `days` from now on the seeded A→B→C route. */ + async function createBookableSchedule(days: number, trainNumber: string) { + const departureAt = daysFromNow(days); + departureAt.setUTCHours(6, 0, 0, 0); + const arrivalAt = new Date(departureAt.getTime() + 6 * 60 * 60 * 1000); + + const train = await harness.prisma.train.create({ + data: { number: trainNumber, name: `Test ${trainNumber}` }, + }); + const coach = await harness.prisma.coach.create({ + data: { + coachTypeId: IDS.coachType, + number: `${trainNumber}-C1`, + capacity: 2, + sequence: 1, + status: "ACTIVE", + }, + }); + await Promise.all( + ["1A", "1B"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: String.fromCharCode(65 + i) }, + }), + ), + ); + const schedule = await schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + departureAt: departureAt.toISOString(), + arrivalAt: arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + + return { schedule, departureDate: addisDateStr(departureAt) }; + } + + function range(days = 30) { + return { from: addisDateStr(new Date()), to: addisDateStr(daysFromNow(days)) }; + } + + it("reports routeExists=false and no dates when no route connects the pair", async () => { + // seed-core's only route runs A→B→C and no schedule exists yet, so nothing connects C→A. + // Every date is unbookable, and the portal disables the date control outright rather than + // graying out each day individually. + const result = await searchService.getAvailableDates({ + originStationId: IDS.stationC, + destinationStationId: IDS.stationA, + ...range(), + } as any); + + expect(result.routeExists).toBe(false); + expect(result.dates).toEqual([]); + }); + + /** + * Regression: `routeExists` must agree with what the search can actually sell. + * + * A return leg reuses the outbound Route but lays its TripStopTimes in the opposite order. + * routeExistsForPair originally consulted only RouteStop ordering, so it answered "no route" + * for C→A while searchTrips happily returned a bookable trip for that same pair. The portal + * disables its date picker on this flag, so the stale answer would have blocked a real, + * sellable journey. + */ + it("reports routeExists=true for a reverse pair a real schedule connects", async () => { + const departureAt = daysFromNow(3); + departureAt.setUTCHours(6, 0, 0, 0); + const train = await harness.prisma.train.create({ + data: { number: "AD-REV", name: "Reverse leg" }, + }); + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: "AD-REV-C1", capacity: 1, sequence: 1, status: "ACTIVE" }, + }); + await harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "A" }, + }); + // Return leg: same Route, but stop times run C → A. + const schedule = await harness.prisma.trainSchedule.create({ + data: { + trainId: train.id, + routeId: IDS.route, + originStationId: IDS.stationC, + destinationStationId: IDS.stationA, + departureAt, + arrivalAt: new Date(departureAt.getTime() + 6 * 60 * 60 * 1000), + durationMinutes: 360, + status: "SCHEDULED", + }, + }); + await harness.prisma.tripStopTime.createMany({ + data: [ + { scheduleId: schedule.id, stationId: IDS.stationC, sequence: 1, plannedDepartureAt: departureAt, status: "OPEN" }, + { scheduleId: schedule.id, stationId: IDS.stationA, sequence: 2, plannedArrivalAt: new Date(departureAt.getTime() + 6 * 3600_000), status: "OPEN" }, + ], + }); + await harness.prisma.coachAssignment.create({ + data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true }, + }); + + const result = await searchService.getAvailableDates({ + originStationId: IDS.stationC, + destinationStationId: IDS.stationA, + ...range(), + } as any); + + expect(result.routeExists).toBe(true); + expect(result.dates.filter((d) => d.available).map((d) => d.date)).toContain( + addisDateStr(departureAt), + ); + }); + + it("reports routeExists=false for a station pair with no route at all", async () => { + const orphan = await harness.prisma.station.create({ + data: { code: "ZZZ", name: "Orphan", city: "Nowhere" }, + }); + + const result = await searchService.getAvailableDates({ + originStationId: IDS.stationA, + destinationStationId: orphan.id, + ...range(), + } as any); + + expect(result.routeExists).toBe(false); + expect(result.dates).toEqual([]); + }); + + it("reports routeExists=true with a per-date list for a connected pair", async () => { + const result = await searchService.getAvailableDates({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + ...range(), + } as any); + + expect(result.routeExists).toBe(true); + expect(result.dates.length).toBeGreaterThan(0); + for (const d of result.dates) { + expect(d).toEqual({ date: expect.any(String), available: expect.any(Boolean) }); + } + }); + + it("marks only the days a bookable schedule departs as available", async () => { + const { departureDate } = await createBookableSchedule(3, "AD-1"); + + const result = await searchService.getAvailableDates({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + ...range(), + } as any); + + expect(result.routeExists).toBe(true); + const available = result.dates.filter((d) => d.available).map((d) => d.date); + expect(available).toContain(departureDate); + // Every other day in the window has no schedule, so it must be reported unavailable — + // this is what grays out individual days on the picker. + expect(available).toEqual([departureDate]); + }); + + it("treats a mid-route segment as its own pair (A→B available, C→B never)", async () => { + const { departureDate } = await createBookableSchedule(4, "AD-2"); + + const forward = await searchService.getAvailableDates({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + ...range(), + } as any); + expect(forward.routeExists).toBe(true); + expect(forward.dates.filter((d) => d.available).map((d) => d.date)).toContain(departureDate); + + // C sits after B on the route, so C→B is backwards — no route, whatever schedules exist. + const backward = await searchService.getAvailableDates({ + originStationId: IDS.stationC, + destinationStationId: IDS.stationB, + ...range(), + } as any); + expect(backward.routeExists).toBe(false); + expect(backward.dates).toEqual([]); + }); + + it("never reports dates before today, even when asked for a past range", async () => { + const result = await searchService.getAvailableDates({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + from: addisDateStr(daysFromNow(-30)), + to: addisDateStr(daysFromNow(5)), + } as any); + + const today = addisDateStr(new Date()); + expect(result.routeExists).toBe(true); + for (const d of result.dates) expect(d.date >= today).toBe(true); + }); + + it("clamps an over-long range to the 90-day server maximum", async () => { + const result = await searchService.getAvailableDates({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + from: addisDateStr(new Date()), + to: addisDateStr(daysFromNow(400)), + } as any); + + expect(result.routeExists).toBe(true); + // Inclusive of both ends, 90 days spans at most 91 calendar dates. + expect(result.dates.length).toBeLessThanOrEqual(91); + expect(result.to <= addisDateStr(daysFromNow(91))).toBe(true); + }); + + it("does not mark a package-only schedule's day as available", async () => { + const { schedule, departureDate } = await createBookableSchedule(5, "AD-3"); + await harness.prisma.trainSchedule.update({ + where: { id: schedule.id }, + data: { isPackageOnly: true }, + }); + + const result = await searchService.getAvailableDates({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + ...range(), + } as any); + + const available = result.dates.filter((d) => d.available).map((d) => d.date); + expect(available).not.toContain(departureDate); + }); + + it("does not mark a cancelled schedule's day as available", async () => { + const { schedule, departureDate } = await createBookableSchedule(6, "AD-4"); + await harness.prisma.trainSchedule.update({ + where: { id: schedule.id }, + data: { status: "CANCELLED" }, + }); + + const result = await searchService.getAvailableDates({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + ...range(), + } as any); + + const available = result.dates.filter((d) => d.available).map((d) => d.date); + expect(available).not.toContain(departureDate); + }); +}); diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 7eede0089..ab44cb799 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -10,7 +10,9 @@ import { Banknote, ArrowRight, ScanLine, + Ban, } from "lucide-react"; +import { SEAT_BLOCK_REASON_CATEGORY_LABELS } from "@edr/types"; import { dashboardApi } from "@/lib/api/dashboard"; import { apiClient } from "@/lib/api-client"; import { formatCurrency } from "@/lib/utils"; @@ -161,6 +163,10 @@ function DashboardPageContent() { return rate !== null ? sum + Math.round(totalMinor * rate) : sum; }, 0); + const blockedLoss = stats?.blockedSeatRevenueLoss; + // Never summed across currencies — each is shown on its own line, largest first. + const blockedLossRows = blockedLoss?.lossByCurrency ?? []; + const normalRows = stats?.revenueByCurrency ?? []; const packageRows = stats?.packageRevenueByCurrency ?? []; const normalGrand = calcGrand(normalRows); @@ -320,6 +326,73 @@ function DashboardPageContent() { )} + + {/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the + dashboard makes no extra request for it. */} +
+
+
+ +
+ + Blocked Seats + + + Last {blockedLoss?.periodDays ?? 30}d + +
+ {statsLoading ? ( +

Loading…

+ ) : ( + <> + {blockedLossRows.length === 0 ? ( +

+ {formatCurrency(0, "ETB")} +

+ ) : ( + blockedLossRows.map((row, i) => ( +

+ {formatCurrency(row.estimatedLossMinor, row.currency)} +

+ )) + )} +

+ Estimated potential revenue never earned +

+
+
+ Seats blocked + + {(blockedLoss?.blockedSeatCount ?? 0).toLocaleString()} across{" "} + {(blockedLoss?.schedulesAffected ?? 0).toLocaleString()} schedules + +
+
+ Top reason + + {blockedLoss?.topReasonCategory + ? (SEAT_BLOCK_REASON_CATEGORY_LABELS[blockedLoss.topReasonCategory] ?? + blockedLoss.topReasonCategory) + : "—"} + +
+
+ + View full report + + + )} +
{/* Revenue breakdown */} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/layout.tsx new file mode 100644 index 000000000..790272de1 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/page.tsx new file mode 100644 index 000000000..33a011157 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/page.tsx @@ -0,0 +1,928 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Ban, + ChevronDown, + ChevronRight, + Download, + Info, + Layers, + TrendingDown, + Train, +} from "lucide-react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip as RechartsTooltip, + XAxis, + YAxis, +} from "recharts"; +import type { + BlockedSeatLossDetail, + BlockedSeatLossSchedule, + BlockedSeatRevenueLossReport, +} from "@edr/types"; +import { + SEAT_BLOCK_REASON_CATEGORIES, + SEAT_BLOCK_REASON_CATEGORY_LABELS, + UNCATEGORIZED_REASON_CATEGORY, +} from "@edr/types"; +import { apiClient } from "@/lib/api-client"; +import { + blockedSeatsLossApi, + type BlockedSeatsLossFilters, + type ScheduleOption, +} from "@/lib/api/blocked-seats-loss"; +import Badge from "@/components/ui/Badge"; +import ActionButton from "@/components/ui/ActionButton"; +import Pagination from "@/components/ui/Pagination"; +import { usePagination } from "@/lib/use-pagination"; +import { formatCurrency, formatDateTime } from "@/lib/utils"; +import { categoricalColor, getChartPalette } from "@/lib/chart-palette"; +import { useTheme } from "@/lib/theme-store"; + +interface RouteOption { + id: string; + name: string; + code: string; +} + +interface TrainOption { + id: string; + number: string; + name: string; +} + +/** Fixed domain order for reason categories, so a filter never repaints the survivors. */ +const REASON_CATEGORY_ORDER: readonly string[] = [ + ...SEAT_BLOCK_REASON_CATEGORIES, + UNCATEGORIZED_REASON_CATEGORY, +]; + +function reasonLabel(category: string | null): string { + const key = category ?? UNCATEGORIZED_REASON_CATEGORY; + return SEAT_BLOCK_REASON_CATEGORY_LABELS[key] ?? key; +} + +function isoDaysAgo(days: number): string { + const d = new Date(); + d.setDate(d.getDate() - days); + return d.toISOString().split("T")[0]; +} + +const TABLE_PAGE_SIZE = 25; + +export default function BlockedSeatRevenueLossPage() { + const isDark = useTheme((s) => s.isDark); + const palette = getChartPalette(isDark); + + // ── Filters ─────────────────────────────────────────────────────────────── + const [dateFrom, setDateFrom] = useState(isoDaysAgo(30)); + const [dateTo, setDateTo] = useState(() => new Date().toISOString().split("T")[0]); + const [scheduleId, setScheduleId] = useState(""); + const [routeId, setRouteId] = useState(""); + const [trainId, setTrainId] = useState(""); + const [reasonCategory, setReasonCategory] = useState(""); + const [blockedBy, setBlockedBy] = useState(""); + const [blockedByInput, setBlockedByInput] = useState(""); + const [sortBy, setSortBy] = useState("lossMinor"); + const [page, setPage] = useState(1); + const [expanded, setExpanded] = useState>(new Set()); + const [showMethodology, setShowMethodology] = useState(false); + const [exporting, setExporting] = useState(false); + + const filters: BlockedSeatsLossFilters = useMemo( + () => ({ + dateFrom, + dateTo, + scheduleId, + routeId, + trainId, + reasonCategory, + blockedBy, + sortBy, + }), + [dateFrom, dateTo, scheduleId, routeId, trainId, reasonCategory, blockedBy, sortBy], + ); + + const { data: schedules = [], isLoading: loadingSchedules } = useQuery({ + queryKey: ["report-schedules-all"], + queryFn: blockedSeatsLossApi.getSchedules, + }); + + const { data: routes = [] } = useQuery({ + queryKey: ["routes"], + queryFn: () => apiClient.get("/routes"), + }); + + const { data: trains = [] } = useQuery({ + queryKey: ["fleet-trains"], + queryFn: async () => { + const res = await apiClient.get( + "/fleet/trains", + ); + return Array.isArray(res) ? res : (res?.items ?? []); + }, + }); + + const { data, isLoading, isError, isFetching } = useQuery({ + queryKey: ["blocked-seats-revenue-loss", filters, page], + // Hold the previous render while refetching rather than flashing a skeleton. + placeholderData: (previous) => previous, + queryFn: () => + blockedSeatsLossApi.getReport({ ...filters, page, pageSize: TABLE_PAGE_SIZE }), + }); + + const summary = data?.summary; + const scheduleRows = data?.schedules ?? []; + const totalPages = Math.max(1, Math.ceil((data?.meta.total ?? 0) / TABLE_PAGE_SIZE)); + + const resetFilters = () => { + setDateFrom(isoDaysAgo(30)); + setDateTo(new Date().toISOString().split("T")[0]); + setScheduleId(""); + setRouteId(""); + setTrainId(""); + setReasonCategory(""); + setBlockedBy(""); + setBlockedByInput(""); + setSortBy("lossMinor"); + setPage(1); + }; + + const onFilterChange = (apply: () => void) => { + apply(); + setPage(1); + setExpanded(new Set()); + }; + + const toggleExpanded = (id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const doExport = async () => { + setExporting(true); + try { + const csv = await blockedSeatsLossApi.exportCsv(filters); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `blocked-seats-revenue-loss-${new Date().toISOString().split("T")[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + }; + + // ── Chart data ──────────────────────────────────────────────────────────── + // Money is only comparable within one currency, so both charts are scoped to the + // dominant currency on this page and say so. + const chartCurrency = summary?.lossByCurrency[0]?.currency ?? "ETB"; + const otherCurrencies = (summary?.lossByCurrency ?? []) + .slice(1) + .map((c) => c.currency); + + const topSchedules = useMemo( + () => + scheduleRows + .filter((s) => s.currency === chartCurrency) + .slice() + .sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor) + .slice(0, 10) + .map((s) => ({ + label: `${s.trainNumber} · ${new Date(s.departureAt).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + })}`, + estimatedLossMinor: s.estimatedLossMinor, + adjustedLossMinor: s.adjustedLossMinor, + blockedSeatCount: s.blockedSeatCount, + scheduleId: s.scheduleId, + })), + [scheduleRows, chartCurrency], + ); + + const reasonBreakdown = useMemo(() => { + const rows = (summary?.topReasonCategories ?? []).filter( + (r) => r.currency === chartCurrency, + ); + const total = rows.reduce((sum, r) => sum + r.estimatedLossMinor, 0); + return rows.map((r) => ({ + ...r, + // Colour by fixed domain position, not by rank in this filtered view. + color: categoricalColor(palette, REASON_CATEGORY_ORDER.indexOf(r.reasonCategory)), + sharePercent: total > 0 ? (r.estimatedLossMinor / total) * 100 : 0, + })); + }, [summary, chartCurrency, palette]); + + const hasData = (summary?.blockedSeatCount ?? 0) > 0; + + return ( +
+
+
+

Blocked Seat Revenue Loss

+

+ Potential fare revenue that could never be earned because seats were blocked + out of sale — with per-seat detail on who blocked each one and why. +

+
+ + Download CSV + +
+ + {/* ── Filter bar: one row above everything it scopes ───────────────── */} +
+
+
+ + onFilterChange(() => setDateFrom(e.target.value))} + /> +
+
+ + onFilterChange(() => setDateTo(e.target.value))} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
{ + e.preventDefault(); + onFilterChange(() => setBlockedBy(blockedByInput.trim())); + }} + > + setBlockedByInput(e.target.value)} + onBlur={() => onFilterChange(() => setBlockedBy(blockedByInput.trim()))} + /> +
+
+
+
+
+ + +
+ +
+ {isError && ( +

+ Failed to load the report. Check the filters and try again. +

+ )} +
+ + {isLoading && !data ? ( +
Loading report…
+ ) : !hasData ? ( +
+ +

No blocked seats cost revenue in this window.

+

+ Widen the date range, or clear the route/train/reason filters. +

+
+ ) : ( +
+ {/* ── Summary tiles ─────────────────────────────────────────────── */} +
+
+
+
+

+ Estimated loss +

+ {summary?.lossByCurrency.map((c, i) => ( +

+ {formatCurrency(c.estimatedLossMinor, c.currency)} +

+ ))} +

At full occupancy

+
+ +
+
+ +
+
+
+

Adjusted loss

+ {summary?.lossByCurrency.map((c, i) => ( +

+ {formatCurrency(c.adjustedLossMinor, c.currency)} +

+ ))} +

+ Scaled by each train's load factor +

+
+ +
+
+ +
+
+
+

Blocked seats

+

+ {summary?.blockedSeatCount.toLocaleString()} +

+

+ Across {summary?.schedulesAffected.toLocaleString()} schedules +

+
+ +
+
+ +
+
+
+

+ Seat-days blocked +

+

+ {scheduleRows + .reduce( + (sum, s) => sum + s.blocks.reduce((n, b) => n + b.daysBlocked, 0), + 0, + ) + .toLocaleString()} +

+

On this page

+
+ +
+
+
+ + {/* ── Charts ────────────────────────────────────────────────────── */} +
+
+

+ Top schedules by estimated loss +

+

+ Estimated loss in {chartCurrency}, largest first + {otherCurrencies.length > 0 && ( + <> · {otherCurrencies.join(", ")} shown in the table below + )} +

+ {topSchedules.length === 0 ? ( +
+ No schedules in {chartCurrency} on this page +
+ ) : ( + + + + (v / 100).toLocaleString()} + axisLine={{ stroke: palette.axis }} + tickLine={false} + /> + + [ + formatCurrency(value, chartCurrency), + name === "estimatedLossMinor" ? "Estimated" : "Adjusted", + ]} + /> + {/* One series, one hue — bar length already encodes magnitude. */} + formatCurrency(v, chartCurrency), + }} + /> + + + )} +
+ +
+

+ Where the loss comes from +

+

+ Share of estimated loss by reason category, in {chartCurrency} +

+ + {/* Part-to-whole: one horizontal stacked bar, 2px surface gaps between + segments (no borders), with a legend carrying identity in text. */} +
`${reasonLabel(r.reasonCategory)} ${r.sharePercent.toFixed(0)}%`) + .join(", ")}`} + > + {reasonBreakdown.map((r, i) => ( +
+ ))} +
+ + {/* Legend doubles as the table view — the numbers are never tooltip-gated. */} + + + + + + + + + + + {reasonBreakdown.map((r) => ( + + + + + + + ))} + +
ReasonSeatsShareEstimated loss
+ + + + {r.count.toLocaleString()} + + {r.sharePercent.toFixed(1)}% + + {formatCurrency(r.estimatedLossMinor, r.currency)} +
+
+
+ + {/* ── Schedule table with per-seat drill-down ───────────────────── */} +
+
+

+ Affected schedules +

+ + Expand a row to see every blocked seat + +
+
+ + + + {[ + "Schedule", + "Route", + "Departure", + "Blocked", + "Load factor", + "Estimated loss", + "Adjusted loss", + ].map((h) => ( + + ))} + + + + {scheduleRows.map((row) => ( + toggleExpanded(row.scheduleId)} + /> + ))} + {scheduleRows.length === 0 && ( + + + + )} + +
+ {h} +
+ No schedules on this page +
+
+ { + setPage(p); + setExpanded(new Set()); + }} + /> +
+
+ )} + + {/* ── Methodology, verbatim from the API ──────────────────────────── */} + {data && ( +
+ + {showMethodology && ( +
+

{data.meta.methodology}

+
+

What is excluded

+
    + {data.meta.exclusions.map((e) => ( +
  • {e}
  • + ))} +
+
+
+
+
Fares priced at nationality
+
{data.meta.nationalityAssumption}
+
+
+
Departure window
+
+ {formatDateTime(data.meta.dateFrom)} — {formatDateTime(data.meta.dateTo)} +
+
+
+
Schedules affected
+
{data.meta.total}
+
+
+
Schedules with no fare on file
+
+ {data.meta.schedulesWithoutFare} +
+
+
+ {data.meta.schedulesWithoutFare > 0 && ( +

+ {data.meta.schedulesWithoutFare} schedule + {data.meta.schedulesWithoutFare === 1 ? "" : "s"} could not be priced (no + route and no fare rules). Their blocked seats are counted, but carry no + monetary claim. +

+ )} +
+ )} +
+ )} +
+ ); +} + +// ── Schedule row + drill-down ─────────────────────────────────────────────── + +function ScheduleRow({ + row, + expanded, + onToggle, +}: { + row: BlockedSeatLossSchedule; + expanded: boolean; + onToggle: () => void; +}) { + return ( + <> + + + + {expanded ? ( + + ) : ( + + )} + {row.trainNumber} + + {row.status} + + + + + {row.originStation} → {row.destinationStation} + + + {formatDateTime(row.departureAt)} + + {row.blockedSeatCount} + + {row.loadFactorPercent}%{" "} + + ({row.soldSeats}/{row.sellableSeats}) + + + + {formatCurrency(row.estimatedLossMinor, row.currency)} + + + {formatCurrency(row.adjustedLossMinor, row.currency)} + + + {expanded && ( + + + + + + )} + + ); +} + +function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) { + const { paged, page, totalPages, setPage } = usePagination(blocks, 50); + + return ( +
+ + + + {[ + "Coach · Seat", + "Class", + "Scope", + "Reason", + "Blocked by", + "Approved by", + "Blocked at", + "Until", + "Days", + "Estimated loss", + ].map((h) => ( + + ))} + + + + {paged.map((b) => ( + + + + + + + + + + + + + ))} + +
+ {h} +
+ {b.coachNumber ?? "—"} · #{b.seatNumber ?? "—"} + + {b.seatClassName ?? "—"} + + {b.blockType === "SCHEDULE" ? "Schedule" : "Global"} + + + {reasonLabel(b.reasonCategory)} + {b.reason} + + + {b.blockedByName ?? (b.blockedBy === "SYSTEM" ? "System" : "Unknown")} + + {b.approvedBy ?? "—"} + + {formatDateTime(b.blockedAt)} + + {b.stillBlocked ? ( + Still blocked + ) : ( + formatDateTime(b.unblockAt) + )} + + {b.daysBlocked} + + {formatCurrency(b.estimatedLossMinor, b.currency)} +
+ {totalPages > 1 && ( + + )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index f1289d24a..c2d3ef2db 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -9,6 +9,11 @@ import { PERMS } from '@/lib/permissions'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react'; +import { + SEAT_BLOCK_REASON_CATEGORIES, + SEAT_BLOCK_REASON_CATEGORY_LABELS, + SeatBlockReasonCategory, +} from '@edr/types'; export default function SeatsPage() { const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route'); @@ -19,9 +24,17 @@ export default function SeatsPage() { const [showRemoveModal, setShowRemoveModal] = useState(false); const [selectedSeat, setSelectedSeat] = useState(null); const [blockReason, setBlockReason] = useState(''); + // Reporting bucket for the block — drives the reason breakdown in the Blocked Seat + // Revenue Loss report. Free-text `reason` stays the operator's detail. + const [blockCategory, setBlockCategory] = useState( + SeatBlockReasonCategory.Other, + ); const [showBlockCoachModal, setShowBlockCoachModal] = useState(false); const [selectedCoach, setSelectedCoach] = useState(null); const [blockCoachReason, setBlockCoachReason] = useState(''); + const [blockCoachCategory, setBlockCoachCategory] = useState( + SeatBlockReasonCategory.Other, + ); const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false); const [coachToUnblock, setCoachToUnblock] = useState(null); const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); @@ -110,13 +123,14 @@ export default function SeatsPage() { }; const blockMutation = useMutation({ - mutationFn: ({ seatId, reason }: any) => - seatsApi.block(seatId, { reason, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }), + mutationFn: ({ seatId, reason, reasonCategory }: any) => + seatsApi.block(seatId, { reason, reasonCategory, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }), onSuccess: () => { invalidateSeatData(); setShowBlockModal(false); setSelectedSeat(null); setBlockReason(''); + setBlockCategory(SeatBlockReasonCategory.Other); }, }); @@ -186,17 +200,18 @@ export default function SeatsPage() { const coaches = activeTab === 'schedule' ? (seatMapData?.coaches || []) : (Array.isArray(routeCoachesData) ? routeCoachesData : []); const blockCoachMutation = useMutation({ - mutationFn: async ({ coachId, reason }: any) => { + mutationFn: async ({ coachId, reason, reasonCategory }: any) => { const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined; - return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, ...(scheduleId ? { scheduleId } : {}) }))); + return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, reasonCategory, ...(scheduleId ? { scheduleId } : {}) }))); }, onSuccess: () => { invalidateSeatData(); setShowBlockCoachModal(false); setSelectedCoach(null); setBlockCoachReason(''); + setBlockCoachCategory(SeatBlockReasonCategory.Other); }, }); @@ -356,7 +371,7 @@ export default function SeatsPage() { alert('Please provide a reason for blocking'); return; } - await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason }); + await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason, reasonCategory: blockCoachCategory }); }; const submitBlock = async () => { @@ -364,7 +379,7 @@ export default function SeatsPage() { alert('Please provide a reason for the reservation'); return; } - await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason }); + await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason, reasonCategory: blockCategory }); }; const submitRemoveSeat = async () => { @@ -874,6 +889,23 @@ export default function SeatsPage() {

Reserve seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber}

+
+ + +

+ Groups this block in the Blocked Seat Revenue Loss report. +

+