Merge branch 'dev' into staging

This commit is contained in:
Nathnael Wondisha
2026-08-03 00:24:17 +03:00
committed by GitHub
45 changed files with 4415 additions and 229 deletions

View File

@@ -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";

View File

@@ -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: [],
};
}

View File

@@ -742,16 +742,7 @@ export default function TrainScheduleV2DetailPage() {
<WagonPlanGrid wagonPlan={displayWagonPlanOriented} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group>
{!hasContainerStep ? (
<Button
color="edr-green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
) : (
{hasContainerStep ? (
<Button
color="edr-green"
radius="md"
@@ -760,7 +751,7 @@ export default function TrainScheduleV2DetailPage() {
>
Continue to containers
</Button>
)}
) : null}
<Button variant="default" radius="md" onClick={() => void runPreview()}>
Refresh preview
</Button>

View File

@@ -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"
>
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
{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"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{completeBookingId
@@ -534,28 +552,41 @@ function NewShipmentBookingForm({
marginTop: "auto",
}}
>
<Group justify="flex-end" className="mx-auto max-w-4xl">
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
{/* Mantine tooltips get no pointer events from a disabled button,
<Box className="mx-auto max-w-4xl">
{showValidationSummary ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</Box>
</form>
@@ -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({
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
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.
</Text>
</Stack>
</Alert>
@@ -810,7 +840,12 @@ function PriceConfirmModal({
</Alert>
)}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
<Paper
withBorder
radius={16}
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Stack gap={10}>
{total.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
@@ -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."
/>
<Alert color="blue" variant="light" radius="md" icon={<AlertCircle size={16} />}>
<Alert
color="blue"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
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 ? (
<Alert color="yellow" variant="light" radius="md" icon={<AlertCircle size={16} />}>
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Enter your cargo details first available shipment days depend on the
wagons your cargo needs.
</Alert>
@@ -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({
/>
<Stack gap={18}>
{sizes.length > 0 && (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Paper
withBorder
radius="md"
p="md"
style={{ borderColor: "#E6ECF2" }}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Box>
<Text fz={13} fw={600} c="#10202F">
@@ -1458,10 +1513,11 @@ function CargoStep({
title={`Odd number of 20ft containers (${ft20})`}
>
<Text fz={13}>
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.
</Text>
</Alert>
);
@@ -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({
))}
</Group>
)}
{Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => (
<Group key={u} gap={10} wrap="nowrap" align="flex-start">
<Controller
name={`containers.${index}.units.${u}.containerNumber`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.sealNumber`}
control={form.control}
render={({ field }) => (
<TextInput
{...field}
placeholder="Optional"
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.vgmTons`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
placeholder="e.g. 24.5"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
{handlingColumns.map((col) => (
{Array.from({ length: Math.max(quantity, units.length) }).map(
(_, u) => (
<Group key={u} gap={10} wrap="nowrap" align="flex-start">
<Controller
key={col.key}
name={`containers.${index}.units.${u}.${col.key}`}
name={`containers.${index}.units.${u}.containerNumber`}
control={form.control}
render={({ field }) => (
<Box
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(field.value)}
aria-label={`${col.label} — container ${u + 1}`}
onChange={(e) =>
toggleUnitHandling(u, col.key, e.currentTarget.checked)
}
size="sm"
/>
</Box>
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${u + 1}`}
onClick={() => removeUnit(u)}
>
<X size={16} />
</ActionIcon>
</Group>
))}
<Controller
name={`containers.${index}.units.${u}.sealNumber`}
control={form.control}
render={({ field }) => (
<TextInput
{...field}
placeholder="Optional"
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.vgmTons`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
placeholder="e.g. 24.5"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
{handlingColumns.map((col) => (
<Controller
key={col.key}
name={`containers.${index}.units.${u}.${col.key}`}
control={form.control}
render={({ field }) => (
<Box
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(field.value)}
aria-label={`${col.label} — container ${u + 1}`}
onChange={(e) =>
toggleUnitHandling(
u,
col.key,
e.currentTarget.checked,
)
}
size="sm"
/>
</Box>
)}
/>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${u + 1}`}
onClick={() => removeUnit(u)}
>
<X size={16} />
</ActionIcon>
</Group>
),
)}
</Stack>
</Box>
);

View File

@@ -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");

View File

@@ -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")
}

View File

@@ -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';
}

View File

@@ -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 {}

View File

@@ -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<BlockedSeatRevenueLossStat> {
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([

View File

@@ -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> = {}): LossCoach {
return {
id: 'coach-1',
number: 'C1',
coachTypeType: 'passenger',
coachTypeName: 'Economy Coach',
seatClasses: [ECONOMY_LOCAL, ECONOMY_INTL],
...overrides,
};
}
function seat(overrides: Partial<LossSeat> = {}): LossSeat {
return {
id: 'seat-1',
coachId: 'coach-1',
seatNumber: '1',
bedPosition: null,
premiumFeeMinor: 0,
...overrides,
};
}
function schedule(overrides: Partial<LossSchedule> = {}): 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> = {}): 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> = {}): 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<string, string[]>;
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> = {}): AssembleOptions {
return {
faresBySchedule: new Map([['sched-1', new Map([[ECONOMY_LOCAL.id, fare()]])]]),
schedulesWithoutFare: new Set<string>(),
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
});
});
});

View File

@@ -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<string, LossSeat>;
/** Every coach involved, keyed by coach id. */
coachesById: Map<string, LossCoach>;
/** Coach ids assigned to each schedule, keyed by schedule id. */
coachIdsBySchedule: Map<string, Set<string>>;
/** `${scheduleId}|${seatId}` for every seat with a CONFIRMED/BOARDED booking. */
soldSeatKeys: Set<string>;
/** 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<string, CountedBlock[]> {
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<string, Map<string, CountedBlock>>();
for (const schedule of schedules) {
if (schedule.status === 'CANCELLED') continue;
const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set<string>();
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<string, CountedBlock>();
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<string, CountedBlock[]>();
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<LossSeat, 'seatNumber'>): 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<LossCoach, 'coachTypeType' | 'coachTypeName'>,
): 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<LossCalculatorInput, 'coachIdsBySchedule' | 'coachesById' | 'seatsById'>,
): 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<string, Map<string, LossFare>>;
/** Schedule ids whose fare calculation failed outright. */
schedulesWithoutFare: Set<string>;
/** '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<string, CountedBlock[]>,
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<string, LossFare>();
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<string, LossFare>,
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<string, LossFare>,
): 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<string>): Map<string, number> {
const counts = new Map<string, number>();
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<string, BlockedSeatLossByCurrency>();
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<string, BlockedSeatLossByReasonCategory>();
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<string, BlockedSeatLossByBlocker>();
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(' ');
}

View File

@@ -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<void> {
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) {

View File

@@ -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;
}

View File

@@ -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]

View File

@@ -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<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
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<string, unknown>;
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<BlockedSeatRevenueLossReport> {
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<string, Map<string, LossFare>>(),
schedulesWithoutFare: new Set<string>(),
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<string, LossCoach>();
const coachIdsBySchedule = new Map<string, Set<string>>();
for (const assignment of assignments) {
const coachIds = coachIdsBySchedule.get(assignment.scheduleId) ?? new Set<string>();
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<string, LossSeat>(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<string>();
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<string, Map<string, LossFare>>;
schedulesWithoutFare: Set<string>;
}> {
const faresBySchedule = new Map<string, Map<string, LossFare>>();
const schedulesWithoutFare = new Set<string>();
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<string, LossFare>();
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<string> {
// 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 },

View File

@@ -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 (

View File

@@ -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")

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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);
});
});

View File

@@ -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() {
</>
)}
</div>
{/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
dashboard makes no extra request for it. */}
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-slate-100 dark:bg-slate-800 p-1.5">
<Ban className="h-4 w-4 text-slate-600 dark:text-slate-400" />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Blocked Seats
</span>
<span className="ml-auto text-[11px] text-muted-foreground">
Last {blockedLoss?.periodDays ?? 30}d
</span>
</div>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : (
<>
{blockedLossRows.length === 0 ? (
<p className="text-3xl font-bold text-foreground tabular-nums">
{formatCurrency(0, "ETB")}
</p>
) : (
blockedLossRows.map((row, i) => (
<p
key={row.currency}
className={
i === 0
? "text-3xl font-bold text-foreground tabular-nums"
: "text-lg font-semibold text-foreground tabular-nums"
}
>
{formatCurrency(row.estimatedLossMinor, row.currency)}
</p>
))
)}
<p className="text-xs text-muted-foreground -mt-1">
Estimated potential revenue never earned
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Seats blocked</span>
<span className="text-sm font-semibold text-foreground tabular-nums">
{(blockedLoss?.blockedSeatCount ?? 0).toLocaleString()} across{" "}
{(blockedLoss?.schedulesAffected ?? 0).toLocaleString()} schedules
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Top reason</span>
<span className="text-sm font-semibold text-foreground">
{blockedLoss?.topReasonCategory
? (SEAT_BLOCK_REASON_CATEGORY_LABELS[blockedLoss.topReasonCategory] ??
blockedLoss.topReasonCategory)
: "—"}
</span>
</div>
</div>
<Link
href="/reports/blocked-seats"
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
>
View full report <ArrowRight className="h-3 w-3" />
</Link>
</>
)}
</div>
</div>
{/* Revenue breakdown */}

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -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<Set<string>>(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<ScheduleOption[]>({
queryKey: ["report-schedules-all"],
queryFn: blockedSeatsLossApi.getSchedules,
});
const { data: routes = [] } = useQuery<RouteOption[]>({
queryKey: ["routes"],
queryFn: () => apiClient.get<RouteOption[]>("/routes"),
});
const { data: trains = [] } = useQuery<TrainOption[]>({
queryKey: ["fleet-trains"],
queryFn: async () => {
const res = await apiClient.get<TrainOption[] | { items: TrainOption[] }>(
"/fleet/trains",
);
return Array.isArray(res) ? res : (res?.items ?? []);
},
});
const { data, isLoading, isError, isFetching } = useQuery<BlockedSeatRevenueLossReport>({
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 (
<div className="space-y-6 p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-3xl font-bold text-foreground">Blocked Seat Revenue Loss</h1>
<p className="text-muted-foreground mt-1">
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.
</p>
</div>
<ActionButton
icon={Download}
variant="secondary"
onClick={doExport}
loading={exporting}
disabled={!hasData}
>
Download CSV
</ActionButton>
</div>
{/* ── Filter bar: one row above everything it scopes ───────────────── */}
<div className="card">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label className="label" htmlFor="bsl-from">
Departing from
</label>
<input
id="bsl-from"
type="date"
className="input"
value={dateFrom}
onChange={(e) => onFilterChange(() => setDateFrom(e.target.value))}
/>
</div>
<div>
<label className="label" htmlFor="bsl-to">
Departing to
</label>
<input
id="bsl-to"
type="date"
className="input"
value={dateTo}
onChange={(e) => onFilterChange(() => setDateTo(e.target.value))}
/>
</div>
<div className="lg:col-span-2">
<label className="label" htmlFor="bsl-schedule">
Schedule
</label>
<select
id="bsl-schedule"
className="input"
value={scheduleId}
disabled={loadingSchedules}
onChange={(e) => onFilterChange(() => setScheduleId(e.target.value))}
>
<option value="">
{loadingSchedules ? "Loading schedules…" : "All schedules"}
</option>
{schedules.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="bsl-route">
Route
</label>
<select
id="bsl-route"
className="input"
value={routeId}
onChange={(e) => onFilterChange(() => setRouteId(e.target.value))}
>
<option value="">All routes</option>
{routes.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="bsl-train">
Train
</label>
<select
id="bsl-train"
className="input"
value={trainId}
onChange={(e) => onFilterChange(() => setTrainId(e.target.value))}
>
<option value="">All trains</option>
{trains.map((t) => (
<option key={t.id} value={t.id}>
{t.number} {t.name}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="bsl-reason">
Reason category
</label>
<select
id="bsl-reason"
className="input"
value={reasonCategory}
onChange={(e) => onFilterChange(() => setReasonCategory(e.target.value))}
>
<option value="">All reasons</option>
{SEAT_BLOCK_REASON_CATEGORIES.map((c) => (
<option key={c} value={c}>
{reasonLabel(c)}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="bsl-blocker">
Blocked by
</label>
<form
onSubmit={(e) => {
e.preventDefault();
onFilterChange(() => setBlockedBy(blockedByInput.trim()));
}}
>
<input
id="bsl-blocker"
type="search"
className="input"
placeholder="Name or user id — press Enter"
value={blockedByInput}
onChange={(e) => setBlockedByInput(e.target.value)}
onBlur={() => onFilterChange(() => setBlockedBy(blockedByInput.trim()))}
/>
</form>
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<label className="text-xs text-muted-foreground" htmlFor="bsl-sort">
Sort by
</label>
<select
id="bsl-sort"
className="input w-56"
value={sortBy}
onChange={(e) => onFilterChange(() => setSortBy(e.target.value))}
>
<option value="lossMinor">Largest estimated loss</option>
<option value="lossMinorAsc">Smallest estimated loss</option>
<option value="blockedSeatCount">Most blocked seats</option>
<option value="departureAt">Earliest departure</option>
</select>
</div>
<button
type="button"
onClick={resetFilters}
className="text-xs text-primary hover:underline"
>
Reset filters
</button>
</div>
{isError && (
<p className="text-xs text-red-500 mt-3">
Failed to load the report. Check the filters and try again.
</p>
)}
</div>
{isLoading && !data ? (
<div className="card py-16 text-center text-muted-foreground">Loading report</div>
) : !hasData ? (
<div className="card py-16 text-center text-muted-foreground">
<Ban className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No blocked seats cost revenue in this window.</p>
<p className="text-xs mt-1">
Widen the date range, or clear the route/train/reason filters.
</p>
</div>
) : (
<div className={isFetching ? "opacity-60 transition-opacity space-y-6" : "space-y-6"}>
{/* ── Summary tiles ─────────────────────────────────────────────── */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="card">
<div className="flex items-start justify-between">
<div className="min-w-0">
<p className="text-muted-foreground text-sm font-medium">
Estimated loss
</p>
{summary?.lossByCurrency.map((c, i) => (
<p
key={c.currency}
className={
i === 0
? "text-2xl font-bold mt-2 text-foreground"
: "text-base font-semibold text-foreground"
}
>
{formatCurrency(c.estimatedLossMinor, c.currency)}
</p>
))}
<p className="text-xs text-muted-foreground mt-1">At full occupancy</p>
</div>
<TrendingDown className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div className="min-w-0">
<p className="text-muted-foreground text-sm font-medium">Adjusted loss</p>
{summary?.lossByCurrency.map((c, i) => (
<p
key={c.currency}
className={
i === 0
? "text-2xl font-bold mt-2 text-foreground"
: "text-base font-semibold text-foreground"
}
>
{formatCurrency(c.adjustedLossMinor, c.currency)}
</p>
))}
<p className="text-xs text-muted-foreground mt-1">
Scaled by each train&apos;s load factor
</p>
</div>
<Layers className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Blocked seats</p>
<p className="text-2xl font-bold mt-2 text-foreground tabular-nums">
{summary?.blockedSeatCount.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground mt-1">
Across {summary?.schedulesAffected.toLocaleString()} schedules
</p>
</div>
<Ban className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">
Seat-days blocked
</p>
<p className="text-2xl font-bold mt-2 text-foreground tabular-nums">
{scheduleRows
.reduce(
(sum, s) => sum + s.blocks.reduce((n, b) => n + b.daysBlocked, 0),
0,
)
.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground mt-1">On this page</p>
</div>
<Train className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
</div>
</div>
</div>
{/* ── Charts ────────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<div className="card">
<h3 className="text-base font-semibold text-foreground">
Top schedules by estimated loss
</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">
Estimated loss in {chartCurrency}, largest first
{otherCurrencies.length > 0 && (
<> · {otherCurrencies.join(", ")} shown in the table below</>
)}
</p>
{topSchedules.length === 0 ? (
<div className="h-[320px] flex items-center justify-center text-muted-foreground text-sm">
No schedules in {chartCurrency} on this page
</div>
) : (
<ResponsiveContainer width="100%" height={40 * topSchedules.length + 48}>
<BarChart
data={topSchedules}
layout="vertical"
margin={{ top: 4, right: 72, bottom: 8, left: 8 }}
barCategoryGap="28%"
>
<CartesianGrid
horizontal={false}
stroke={palette.grid}
strokeWidth={1}
/>
<XAxis
type="number"
tick={{ fontSize: 11, fill: palette.textMuted }}
tickFormatter={(v: number) => (v / 100).toLocaleString()}
axisLine={{ stroke: palette.axis }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="label"
width={128}
tick={{ fontSize: 11, fill: palette.textMuted }}
axisLine={false}
tickLine={false}
/>
<RechartsTooltip
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
contentStyle={{
background: palette.tooltipBg,
border: `1px solid ${palette.tooltipBorder}`,
borderRadius: 8,
fontSize: 12,
}}
formatter={(value: number, name: string) => [
formatCurrency(value, chartCurrency),
name === "estimatedLossMinor" ? "Estimated" : "Adjusted",
]}
/>
{/* One series, one hue — bar length already encodes magnitude. */}
<Bar
dataKey="estimatedLossMinor"
fill={palette.sequential}
radius={[0, 4, 4, 0]}
maxBarSize={24}
isAnimationActive={false}
label={{
position: "right",
fontSize: 11,
fill: palette.textMuted,
formatter: (v: number) => formatCurrency(v, chartCurrency),
}}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="card">
<h3 className="text-base font-semibold text-foreground">
Where the loss comes from
</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">
Share of estimated loss by reason category, in {chartCurrency}
</p>
{/* Part-to-whole: one horizontal stacked bar, 2px surface gaps between
segments (no borders), with a legend carrying identity in text. */}
<div
className="flex w-full h-7 rounded-md overflow-hidden"
role="img"
aria-label={`Estimated loss by reason category: ${reasonBreakdown
.map((r) => `${reasonLabel(r.reasonCategory)} ${r.sharePercent.toFixed(0)}%`)
.join(", ")}`}
>
{reasonBreakdown.map((r, i) => (
<div
key={r.reasonCategory}
className="h-full"
style={{
width: `${r.sharePercent}%`,
background: r.color,
marginRight: i < reasonBreakdown.length - 1 ? 2 : 0,
}}
title={`${reasonLabel(r.reasonCategory)}${formatCurrency(
r.estimatedLossMinor,
r.currency,
)}`}
/>
))}
</div>
{/* Legend doubles as the table view — the numbers are never tooltip-gated. */}
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium py-2">Reason</th>
<th className="text-right font-medium py-2">Seats</th>
<th className="text-right font-medium py-2">Share</th>
<th className="text-right font-medium py-2">Estimated loss</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{reasonBreakdown.map((r) => (
<tr key={r.reasonCategory}>
<td className="py-2">
<span className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-sm shrink-0"
style={{ background: r.color }}
aria-hidden="true"
/>
<span className="text-foreground">
{reasonLabel(r.reasonCategory)}
</span>
</span>
</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">
{r.count.toLocaleString()}
</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">
{r.sharePercent.toFixed(1)}%
</td>
<td className="py-2 text-right tabular-nums text-foreground font-medium">
{formatCurrency(r.estimatedLossMinor, r.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── Schedule table with per-seat drill-down ───────────────────── */}
<div className="card p-0">
<div className="px-4 pt-4 pb-3 flex items-center justify-between gap-4 flex-wrap">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Affected schedules
</h3>
<span className="text-xs text-muted-foreground">
Expand a row to see every blocked seat
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{[
"Schedule",
"Route",
"Departure",
"Blocked",
"Load factor",
"Estimated loss",
"Adjusted loss",
].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{scheduleRows.map((row) => (
<ScheduleRow
key={row.scheduleId}
row={row}
expanded={expanded.has(row.scheduleId)}
onToggle={() => toggleExpanded(row.scheduleId)}
/>
))}
{scheduleRows.length === 0 && (
<tr>
<td
colSpan={7}
className="py-8 text-center text-sm text-muted-foreground"
>
No schedules on this page
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={(p) => {
setPage(p);
setExpanded(new Set());
}}
/>
</div>
</div>
)}
{/* ── Methodology, verbatim from the API ──────────────────────────── */}
{data && (
<div className="card">
<button
type="button"
onClick={() => setShowMethodology((v) => !v)}
className="flex w-full items-center gap-2 text-left"
aria-expanded={showMethodology}
>
{showMethodology ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Info className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold text-foreground">
How this is calculated
</span>
</button>
{showMethodology && (
<div className="mt-4 space-y-4 text-sm text-muted-foreground">
<p className="leading-relaxed">{data.meta.methodology}</p>
<div>
<p className="font-medium text-foreground mb-2">What is excluded</p>
<ul className="list-disc pl-5 space-y-1">
{data.meta.exclusions.map((e) => (
<li key={e}>{e}</li>
))}
</ul>
</div>
<dl className="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
<div className="flex justify-between gap-4">
<dt>Fares priced at nationality</dt>
<dd className="text-foreground">{data.meta.nationalityAssumption}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Departure window</dt>
<dd className="text-foreground">
{formatDateTime(data.meta.dateFrom)} {formatDateTime(data.meta.dateTo)}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Schedules affected</dt>
<dd className="text-foreground tabular-nums">{data.meta.total}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Schedules with no fare on file</dt>
<dd className="text-foreground tabular-nums">
{data.meta.schedulesWithoutFare}
</dd>
</div>
</dl>
{data.meta.schedulesWithoutFare > 0 && (
<p className="text-xs">
{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.
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
// ── Schedule row + drill-down ───────────────────────────────────────────────
function ScheduleRow({
row,
expanded,
onToggle,
}: {
row: BlockedSeatLossSchedule;
expanded: boolean;
onToggle: () => void;
}) {
return (
<>
<tr
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
onClick={onToggle}
>
<td className="px-4 py-3 whitespace-nowrap">
<span className="flex items-center gap-2">
{expanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="font-semibold text-foreground">{row.trainNumber}</span>
<Badge variant="status" status={row.status}>
{row.status}
</Badge>
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{row.originStation} {row.destinationStation}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(row.departureAt)}
</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{row.blockedSeatCount}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground tabular-nums">
{row.loadFactorPercent}%{" "}
<span className="opacity-70">
({row.soldSeats}/{row.sellableSeats})
</span>
</td>
<td className="px-4 py-3 tabular-nums font-medium whitespace-nowrap">
{formatCurrency(row.estimatedLossMinor, row.currency)}
</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap text-muted-foreground">
{formatCurrency(row.adjustedLossMinor, row.currency)}
</td>
</tr>
{expanded && (
<tr>
<td colSpan={7} className="bg-gray-50/60 dark:bg-gray-800/40 px-4 py-4">
<BlockDetailTable blocks={row.blocks} />
</td>
</tr>
)}
</>
);
}
function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
const { paged, page, totalPages, setPage } = usePagination(blocks, 50);
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-[11px] uppercase tracking-wider text-muted-foreground">
{[
"Coach · Seat",
"Class",
"Scope",
"Reason",
"Blocked by",
"Approved by",
"Blocked at",
"Until",
"Days",
"Estimated loss",
].map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{paged.map((b) => (
<tr key={b.blockId}>
<td className="px-3 py-2 whitespace-nowrap font-medium text-foreground">
{b.coachNumber ?? "—"} · #{b.seatNumber ?? "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{b.seatClassName ?? "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<Badge>{b.blockType === "SCHEDULE" ? "Schedule" : "Global"}</Badge>
</td>
<td className="px-3 py-2 max-w-sm">
<span className="flex flex-col gap-1">
<Badge className="w-fit">{reasonLabel(b.reasonCategory)}</Badge>
<span className="text-muted-foreground break-words">{b.reason}</span>
</span>
</td>
<td className="px-3 py-2 whitespace-nowrap text-foreground">
{b.blockedByName ?? (b.blockedBy === "SYSTEM" ? "System" : "Unknown")}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{b.approvedBy ?? "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatDateTime(b.blockedAt)}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{b.stillBlocked ? (
<span className="text-amber-600 dark:text-amber-400">Still blocked</span>
) : (
formatDateTime(b.unblockAt)
)}
</td>
<td className="px-3 py-2 whitespace-nowrap tabular-nums text-muted-foreground">
{b.daysBlocked}
</td>
<td className="px-3 py-2 whitespace-nowrap tabular-nums font-medium text-foreground">
{formatCurrency(b.estimatedLossMinor, b.currency)}
</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
)}
</div>
);
}

View File

@@ -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<any>(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>(
SeatBlockReasonCategory.Other,
);
const [showBlockCoachModal, setShowBlockCoachModal] = useState(false);
const [selectedCoach, setSelectedCoach] = useState<any>(null);
const [blockCoachReason, setBlockCoachReason] = useState('');
const [blockCoachCategory, setBlockCoachCategory] = useState<SeatBlockReasonCategory>(
SeatBlockReasonCategory.Other,
);
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
const [coachToUnblock, setCoachToUnblock] = useState<any>(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() {
<p className="text-sm text-muted-foreground">
Reserve seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div>
<label className="label">Reason Category</label>
<select
className="input"
value={blockCategory}
onChange={(e) => setBlockCategory(e.target.value as SeatBlockReasonCategory)}
>
{SEAT_BLOCK_REASON_CATEGORIES.map((c) => (
<option key={c} value={c}>
{SEAT_BLOCK_REASON_CATEGORY_LABELS[c]}
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
Groups this block in the Blocked Seat Revenue Loss report.
</p>
</div>
<div>
<label className="label">Reason for Reservation *</label>
<textarea
@@ -1150,6 +1182,20 @@ export default function SeatsPage() {
This will block all {selectedCoach?.seats?.length || 0} seats in this coach.
</p>
</div>
<div>
<label className="label">Reason Category</label>
<select
className="input"
value={blockCoachCategory}
onChange={(e) => setBlockCoachCategory(e.target.value as SeatBlockReasonCategory)}
>
{SEAT_BLOCK_REASON_CATEGORIES.map((c) => (
<option key={c} value={c}>
{SEAT_BLOCK_REASON_CATEGORY_LABELS[c]}
</option>
))}
</select>
</div>
<div>
<label className="label">Reason for Blocking *</label>
<textarea

View File

@@ -33,6 +33,7 @@ import {
Moon,
Sun,
Armchair,
Ban,
Grid3x3,
Banknote,
Activity,
@@ -123,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view },
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },

View File

@@ -0,0 +1,54 @@
import { apiClient } from '@/lib/api-client';
import type { BlockedSeatRevenueLossReport } from '@edr/types';
/** One entry of `GET /reports/schedules`. */
export interface ScheduleOption {
id: string;
label: string;
departureAt: string;
isPackage: boolean;
}
/** Every filter the report accepts. Empty strings are dropped before the request. */
export interface BlockedSeatsLossFilters {
dateFrom?: string;
dateTo?: string;
scheduleId?: string;
routeId?: string;
trainId?: string;
coachId?: string;
reasonCategory?: string;
blockedBy?: string;
nationality?: string;
page?: number;
pageSize?: number;
sortBy?: string;
}
/** Serializes filters, omitting blanks so the API applies its own defaults. */
export function toQueryString(filters: BlockedSeatsLossFilters): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === '') continue;
params.set(key, String(value));
}
return params.toString();
}
export const blockedSeatsLossApi = {
getReport: (filters: BlockedSeatsLossFilters) =>
apiClient.get<BlockedSeatRevenueLossReport>(
`/reports/blocked-seats-revenue-loss?${toQueryString(filters)}`,
),
getSchedules: () => apiClient.get<ScheduleOption[]>('/reports/schedules?all=true'),
/**
* CSV export. `getRaw` because the endpoint streams a bare CSV body with no
* `{ success, data }` envelope for `get` to unwrap.
*/
exportCsv: (filters: BlockedSeatsLossFilters) =>
apiClient.getRaw<string>(
`/reports/blocked-seats-revenue-loss/export?${toQueryString(filters)}`,
),
};

View File

@@ -1,4 +1,5 @@
import { apiClient } from '@/lib/api-client';
import type { BlockedSeatRevenueLossStat } from '@edr/types';
import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = {
@@ -12,6 +13,7 @@ export const dashboardApi = {
totalPackageTickets: number;
totalPassengers: number;
blockedSeatsCount: number;
blockedSeatRevenueLoss: BlockedSeatRevenueLossStat;
revenueByCurrency: { currency: string; totalMinor: number }[];
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
}>('/dashboard/backoffice-stats');

View File

@@ -0,0 +1,66 @@
/**
* Chart palette.
*
* Categorical slots are assigned in fixed order and never cycled — a series keeps its
* hue when a filter removes its neighbours. Both modes are separately stepped for their
* own surface, not an automatic flip of the light values.
*
* Validated against this app's card surfaces (light `#ffffff`, dark `#0f1729`) with the
* dataviz six-check validator, six slots, adjacent pairlist:
* light — CVD ΔE 9.1, normal-vision ΔE 19.6, contrast WARN on aqua/yellow/magenta
* dark — CVD ΔE 8.4, normal-vision ΔE 19.3, contrast all ≥ 3:1
* The light-mode contrast WARN obliges *relief*: every chart using these slots ships a
* legend with visible text labels and a table view of the same numbers.
*/
export interface ChartPalette {
/** Categorical slots, in fixed assignment order. */
categorical: readonly string[];
/** Single hue for magnitude — one colour for every bar in a one-series chart. */
sequential: string;
/** Recessive chrome. */
grid: string;
axis: string;
/** Text tokens — labels never wear the data colour. */
textMuted: string;
/** Surface, for the 2px gaps and rings that separate marks. */
surface: string;
tooltipBg: string;
tooltipBorder: string;
}
const LIGHT: ChartPalette = {
categorical: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300'],
sequential: '#2a78d6',
grid: '#e1e0d9',
axis: '#c3c2b7',
textMuted: '#898781',
surface: '#ffffff',
tooltipBg: '#ffffff',
tooltipBorder: 'rgba(11,11,11,0.10)',
};
const DARK: ChartPalette = {
categorical: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300'],
sequential: '#3987e5',
grid: '#2c2c2a',
axis: '#383835',
textMuted: '#898781',
surface: '#0f1729',
tooltipBg: '#0f1729',
tooltipBorder: 'rgba(255,255,255,0.10)',
};
export function getChartPalette(isDark: boolean): ChartPalette {
return isDark ? DARK : LIGHT;
}
/**
* Colour for a categorical member, keyed by its position in a **stable** ordering of the
* whole domain — never by its rank in the current filtered view, so filtering does not
* repaint the survivors. Past the last slot everything folds into one neutral bucket
* rather than inventing a hue no CVD check would pass.
*/
export function categoricalColor(palette: ChartPalette, index: number): string {
return palette.categorical[index] ?? palette.textMuted;
}

View File

@@ -727,11 +727,21 @@ export default function SearchPage() {
const disabledDates = useMemo(() => {
const set = new Set<string>();
// routeExists === false is handled by disabling the date control outright
// (noRouteForPair below), not by enumerating dates — the server returns an
// empty `dates` array in that case anyway.
if (!availableDates?.routeExists) return set;
for (const d of availableDates.dates) if (!d.available) set.add(d.date);
return set;
}, [availableDates]);
// No route connects the chosen From/To, so no date could ever produce a trip. The date
// picker is disabled outright rather than left selectable: graying out individual days
// would imply the date is the problem when the station pair is, and letting someone pick
// a date only to fail at submit wastes the interaction.
const noRouteForPair =
!!originId && !!destId && availableDates?.routeExists === false;
// /search/available-dates only ever reports on a bounded window (server-clamped to 90 days —
// see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that
// window (it was simply never fetched, not confirmed available), so once a route is picked
@@ -758,6 +768,15 @@ export default function SearchPage() {
}
}, [departureDate, disabledDates, setValue, setError]);
// Losing the route invalidates any date already chosen — clear both legs so a stale value
// cannot be submitted from behind a now-disabled control.
useEffect(() => {
if (noRouteForPair && departureDate) {
setValue("departureDate", "");
clearErrors("departureDate");
}
}, [noRouteForPair, departureDate, setValue, clearErrors]);
// Same idea as the departure-date availability above, but for the return leg — which travels
// destination -> origin, the reverse pair. Only fetched for round trips once both stations are
// picked; deliberately unaware of which specific outbound schedule will end up chosen (that's
@@ -789,6 +808,14 @@ export default function SearchPage() {
return set;
}, [returnAvailableDates]);
// The return leg travels destination → origin, so it has its own route-existence answer:
// a one-way route (A→C with no C→A) leaves the outbound date pickable but the return not.
const noReturnRouteForPair =
tripType === "ROUND_TRIP" &&
!!originId &&
!!destId &&
returnAvailableDates?.routeExists === false;
const returnMaxDate = originId && destId && tripType === "ROUND_TRIP" ? maxSearchDate : undefined;
// If the currently selected return date becomes unavailable (From/To changed, or the
@@ -805,6 +832,13 @@ export default function SearchPage() {
}
}, [returnDate, returnDisabledDates, setValue, setError]);
useEffect(() => {
if (noReturnRouteForPair && returnDate) {
setValue("returnDate", "");
clearErrors("returnDate");
}
}, [noReturnRouteForPair, returnDate, setValue, clearErrors]);
const saveRecent = useCallback((id: string) => {
setRecentStationIds((prev) => {
const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
@@ -1242,10 +1276,19 @@ export default function SearchPage() {
minDate={new Date()}
maxDate={departureMaxDate}
disabledDates={disabledDates}
disabled={noRouteForPair}
placeholder="Departure date"
error={!!errors.departureDate}
/>
</div>
{noRouteForPair && (
<p
data-testid="no-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No route connects these stations pick a different destination.
</p>
)}
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
@@ -1278,11 +1321,20 @@ export default function SearchPage() {
}
maxDate={returnMaxDate}
disabledDates={returnDisabledDates}
disabled={noReturnRouteForPair}
placeholder="Return date"
error={!!errors.returnDate}
/>
</div>
{errors.returnDate && (
{noReturnRouteForPair && (
<p
data-testid="no-return-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No return route from this destination.
</p>
)}
{errors.returnDate && (
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>
@@ -1436,11 +1488,20 @@ export default function SearchPage() {
minDate={new Date()}
maxDate={departureMaxDate}
disabledDates={disabledDates}
disabled={noRouteForPair}
placeholder="Departure"
error={!!errors.departureDate}
/>
</div>
{errors.departureDate && (
{noRouteForPair && (
<p
data-testid="no-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No route connects these stations pick a different destination.
</p>
)}
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
@@ -1592,9 +1653,18 @@ export default function SearchPage() {
minDate={new Date()}
maxDate={departureMaxDate}
disabledDates={disabledDates}
disabled={noRouteForPair}
placeholder="Departure date"
/>
{errors.departureDate && (
{noRouteForPair && (
<p
data-testid="no-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No route connects these stations pick a different destination.
</p>
)}
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
@@ -1625,9 +1695,18 @@ export default function SearchPage() {
}
maxDate={returnMaxDate}
disabledDates={returnDisabledDates}
disabled={noReturnRouteForPair}
placeholder="Return date"
/>
{errors.returnDate && (
{noReturnRouteForPair && (
<p
data-testid="no-return-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No return route from this destination.
</p>
)}
{errors.returnDate && (
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>

View File

@@ -21,6 +21,12 @@ interface ModernDatePickerProps {
// Dates with no bookable schedule for the selected route (YYYY-MM-DD keys) — disabled
// alongside the minDate/maxDate range, not just clamping it.
disabledDates?: Set<string>;
/**
* Disables the whole control — the calendar cannot be opened at all. Used when no route
* exists between the selected stations, where every date is unselectable and graying out
* individual days would be misleading (the problem is the route, not the date).
*/
disabled?: boolean;
placeholder?: string;
error?: boolean;
}
@@ -38,6 +44,7 @@ export default function ModernDatePicker({
minDate,
maxDate,
disabledDates,
disabled = false,
placeholder = 'Select date',
error = false,
}: ModernDatePickerProps) {
@@ -52,6 +59,13 @@ export default function ModernDatePicker({
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
const containerRef = useRef<HTMLDivElement>(null);
// If the control becomes disabled while the calendar is open (e.g. the user changes
// the destination to one with no route), close it rather than leaving a live calendar
// floating over a disabled field.
useEffect(() => {
if (disabled) setIsOpen(false);
}, [disabled]);
useEffect(() => {
const check = () => setIsMobileView(window.innerWidth < 768);
check();
@@ -297,8 +311,10 @@ export default function ModernDatePicker({
{/* Trigger button */}
<button
type="button"
onClick={() => setIsOpen(true)}
className={`w-full min-w-0 px-2.5 sm:px-3.5 py-3.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between gap-1.5 bg-white dark:bg-gray-800 transition-all group ${
onClick={() => !disabled && setIsOpen(true)}
disabled={disabled}
aria-disabled={disabled}
className={`w-full min-w-0 px-2.5 sm:px-3.5 py-3.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between gap-1.5 bg-white dark:bg-gray-800 transition-all group disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:border-gray-200 dark:disabled:hover:border-gray-700 ${
error
? 'border-red-400 hover:border-red-400'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'

View File

@@ -195,11 +195,20 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
const disabledDates = useMemo(() => {
const set = new Set<string>();
if (!availableDates) return set;
if (!availableDates.routeExists) return set; // no route → don't blanket-disable every date, the submit-time error already covers this
// routeExists === false is handled by disabling the whole control (noRouteForPair below)
// rather than by enumerating every date as unavailable — the server returns an empty
// `dates` array in that case anyway.
if (!availableDates.routeExists) return set;
for (const d of availableDates.dates) if (!d.available) set.add(d.date);
return set;
}, [availableDates]);
// No route connects the chosen stations, so no date could ever yield a trip. The date
// field is disabled outright: letting someone pick a date and only telling them after
// they submit wastes the interaction, and graying out individual days would imply the
// date is the problem when the station pair is.
const noRouteForPair = !!originId && !!destinationId && availableDates?.routeExists === false;
// /search/available-dates only ever reports on a bounded window (server-clamped to 90 days —
// see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that
// window (it was simply never fetched, not confirmed available), so once a route is picked
@@ -225,6 +234,15 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
}
}, [departureDate, disabledDates, setValue, setError]);
// Losing the route invalidates any date already chosen — clear it so a stale value can't
// be submitted from behind the now-disabled control.
useEffect(() => {
if (noRouteForPair && departureDate) {
setValue('departureDate', '');
clearErrors('departureDate');
}
}, [noRouteForPair, departureDate, setValue, clearErrors]);
const onSubmit = (data: SearchForm) => {
setSearchCriteria({ ...data });
const params = new URLSearchParams({
@@ -283,18 +301,29 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{/* Date */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Date</label>
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => {
setValue('departureDate', toDateStr(date));
clearErrors('departureDate');
}}
minDate={new Date()}
maxDate={departureMaxDate}
disabledDates={disabledDates}
placeholder="Select date"
/>
{errors.departureDate && (
<div data-testid="departure-date-field">
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => {
setValue('departureDate', toDateStr(date));
clearErrors('departureDate');
}}
minDate={new Date()}
maxDate={departureMaxDate}
disabledDates={disabledDates}
disabled={noRouteForPair}
placeholder={noRouteForPair ? 'No route available' : 'Select date'}
/>
</div>
{noRouteForPair && (
<p
data-testid="no-route-notice"
className="text-amber-600 dark:text-amber-400 text-sm"
>
No route connects these stations pick a different destination.
</p>
)}
{!noRouteForPair && errors.departureDate && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
)}
</div>

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,136 @@
import { test, expect, type Page } from "@playwright/test";
import { API_URL, STATIONS, staffToken } from "../../fixtures/data";
/**
* Search form — date selectability is driven by whether a route actually exists.
*
* Two behaviours, both backed by GET /search/available-dates:
*
* 1. No route between the selected From/To → the Date control is DISABLED outright and an
* explanation is shown. Letting someone pick a date and only failing at submit wastes the
* interaction, and graying out individual days would imply the date is the problem when
* the station pair is.
* 2. A route exists → the control is enabled, and only the days with no bookable schedule
* are individually disabled inside the calendar.
*
* The form prefills From/To from the URL query (see booking/search/page.tsx), so these specs set
* the pair deterministically instead of driving the station modal across three layout variants.
*
* Runs in the `guest` project — the search form needs no authentication.
*/
/** A station on no route at all — created per-run so the shared seed stays untouched. */
async function createOrphanStation(request: Page["request"]): Promise<string> {
const res = await request.post(`${API_URL}/stations`, {
headers: { Authorization: `Bearer ${staffToken()}` },
data: {
code: `ORP${Date.now().toString().slice(-4)}`,
name: `Orphan ${Date.now()}`,
city: "Nowhere",
},
});
expect(res.ok(), `station create failed: ${res.status()} ${await res.text()}`).toBeTruthy();
const body = await res.json();
return body?.data?.id ?? body?.id;
}
function searchUrl(origin: string, destination: string): string {
return `/booking/search?origin=${origin}&destination=${destination}`;
}
/** The departure-date trigger. Several layout variants exist; only one is visible at a time. */
function dateTrigger(page: Page) {
return page
.locator("button")
.filter({ hasText: /^(Departure date|Departure|Select date|Date)$/i })
.first();
}
test.describe("search date availability", () => {
test("no route between the stations disables the date picker", async ({ page, request }) => {
const orphanId = await createOrphanStation(request);
await page.goto(searchUrl(STATIONS.A, orphanId), { waitUntil: "domcontentloaded" });
// The notice only renders once /search/available-dates has answered routeExists:false.
await expect(page.getByTestId("no-route-notice").first()).toBeVisible({ timeout: 20_000 });
await expect(dateTrigger(page)).toBeDisabled();
});
test("a disabled date picker cannot be opened", async ({ page, request }) => {
const orphanId = await createOrphanStation(request);
await page.goto(searchUrl(STATIONS.A, orphanId), { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("no-route-notice").first()).toBeVisible({ timeout: 20_000 });
// force:true bypasses Playwright's own actionability guard, so this asserts the app
// really refuses to open — not merely that the button looks unclickable.
await dateTrigger(page).click({ force: true }).catch(() => {});
await page.waitForTimeout(500);
await expect(page.getByRole("button", { name: /^\d{1,2}$/ })).toHaveCount(0);
});
test("a route that exists leaves the date picker enabled and openable", async ({ page }) => {
await page.goto(searchUrl(STATIONS.A, STATIONS.C), { waitUntil: "domcontentloaded" });
// Give the availability query the same chance to resolve as the no-route case gets.
await page.waitForTimeout(3_000);
await expect(page.getByTestId("no-route-notice")).toHaveCount(0);
const trigger = dateTrigger(page);
await expect(trigger).toBeEnabled();
await trigger.click();
// Calendar day cells confirm it actually opened.
await expect(page.getByRole("button", { name: /^\d{1,2}$/ }).first()).toBeVisible({
timeout: 10_000,
});
});
test("dates with no bookable schedule are individually disabled", async ({ page }) => {
await page.goto(searchUrl(STATIONS.A, STATIONS.C), { waitUntil: "domcontentloaded" });
await page.waitForTimeout(3_000);
await dateTrigger(page).click();
const dayCells = page.getByRole("button", { name: /^\d{1,2}$/ });
await expect(dayCells.first()).toBeVisible({ timeout: 10_000 });
// The seed creates exactly one bookable departure in the window, so the calendar must
// contain both enabled and disabled days — never all-enabled (which would mean the
// availability data was ignored) and never all-disabled (which would mean it was
// misapplied to a route that does have a trip).
const total = await dayCells.count();
let disabled = 0;
for (let i = 0; i < total; i++) {
if (await dayCells.nth(i).isDisabled()) disabled++;
}
expect(total).toBeGreaterThan(0);
expect(disabled).toBeGreaterThan(0);
expect(disabled).toBeLessThan(total);
});
test("switching to a routeless destination clears an already-chosen date", async ({
page,
request,
}) => {
const orphanId = await createOrphanStation(request);
// Start on a valid pair with a date already in the URL, so a value is definitely set.
const withDate = new Date();
withDate.setDate(withDate.getDate() + 2);
const dateStr = withDate.toISOString().slice(0, 10);
await page.goto(`${searchUrl(STATIONS.A, STATIONS.C)}&date=${dateStr}`, {
waitUntil: "domcontentloaded",
});
await page.waitForTimeout(2_500);
// Now navigate to the routeless pair — the stale date must not survive behind the
// disabled control, or a doomed search could still be submitted.
await page.goto(searchUrl(STATIONS.A, orphanId), { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("no-route-notice").first()).toBeVisible({ timeout: 20_000 });
const trigger = dateTrigger(page);
await expect(trigger).toBeDisabled();
// Placeholder text (not a formatted date) proves the value was cleared.
await expect(trigger).not.toContainText(/\d{4}/);
});
});

View File

@@ -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 =

View File

@@ -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'`,

View File

@@ -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<ScheduleRow>(
@@ -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) {

View File

@@ -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,

View File

@@ -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'

View File

@@ -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'`,

View File

@@ -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;

View File

@@ -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,

View File

@@ -2,5 +2,9 @@ export * from "./common/index";
export * from "./freight/index";
export * as Freight from "./freight/index";
export * as Passenger from "./passenger/index";
// Flat re-export: the Blocked Seat Revenue Loss shapes are shared verbatim between the
// passenger API and the backoffice, and reading them through the `Passenger.` namespace
// on every line buys nothing.
export * from "./passenger/blocked-seat-revenue-loss";
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent, PaymentIntentSnapshot, InitiatePaymentRequest } from "./common/payments";
export { PaymentReferenceType, PaymentService } from "./common/payments";

View File

@@ -0,0 +1,169 @@
/**
* Blocked Seat Revenue Loss report — shared shapes for
* `GET /reports/blocked-seats-revenue-loss`.
*
* Every monetary field is an **integer count of minor units** (ETB cents, DJF centimes …)
* and is always paired with its `currency`. Never sum across currencies.
*/
/**
* Why a seat was pulled out of sale. Mirrors the Prisma `SeatBlockReasonCategory` enum.
*
* Declared as a const object + union type rather than a TS `enum` so that Prisma's own
* generated string-literal union assigns to it directly, with no cast at the boundary.
*/
export const SeatBlockReasonCategory = {
Maintenance: "MAINTENANCE",
VipReserved: "VIP_RESERVED",
Safety: "SAFETY",
Operational: "OPERATIONAL",
Other: "OTHER",
} as const;
export type SeatBlockReasonCategory =
(typeof SeatBlockReasonCategory)[keyof typeof SeatBlockReasonCategory];
/** Every category value, in the order they should appear in a picker. */
export const SEAT_BLOCK_REASON_CATEGORIES: readonly SeatBlockReasonCategory[] =
Object.values(SeatBlockReasonCategory);
/** Human labels for {@link SeatBlockReasonCategory}, plus the legacy null bucket. */
export const SEAT_BLOCK_REASON_CATEGORY_LABELS: Record<string, string> = {
MAINTENANCE: "Maintenance",
VIP_RESERVED: "VIP Reserved",
SAFETY: "Safety",
OPERATIONAL: "Operational",
OTHER: "Other",
UNCATEGORIZED: "Uncategorized",
};
/** Bucket label used for blocks written before `reasonCategory` existed. */
export const UNCATEGORIZED_REASON_CATEGORY = "UNCATEGORIZED";
/**
* How the block reached this schedule.
* - `SCHEDULE` — a `SeatBlock` row naming this `scheduleId` directly.
* - `GLOBAL` — a `SeatBlock` row with no `scheduleId`, in effect at departure, whose
* seat's coach was assigned to this schedule.
*/
export type BlockedSeatBlockType = "SCHEDULE" | "GLOBAL";
/** One blocked seat on one schedule — the drill-down row. */
export interface BlockedSeatLossDetail {
blockId: string;
seatId: string;
coachNumber: string | null;
seatNumber: string | null;
seatClassName: string | null;
/** Operator's free-text detail, verbatim. */
reason: string;
/** `null` on rows written before the column existed — render as "Uncategorized". */
reasonCategory: SeatBlockReasonCategory | null;
blockType: BlockedSeatBlockType;
/** IAM user id, or `SYSTEM` for system-initiated blocks. */
blockedBy: string;
/** `null` on legacy rows — render as "Unknown". */
blockedByName: string | null;
approvedBy: string | null;
blockedAt: string;
unblockAt: string | null;
/** True when the block has no scheduled end. */
stillBlocked: boolean;
/** Whole days from `blockedAt` to `unblockAt`, or to now while still blocked. */
daysBlocked: number;
estimatedLossMinor: number;
currency: string;
}
/** One schedule with at least one blocked seat counted against it. */
export interface BlockedSeatLossSchedule {
scheduleId: string;
trainNumber: string;
routeName: string | null;
originStation: string;
destinationStation: string;
departureAt: string;
status: string;
/** Non-dining, non-placeholder seats on the coaches assigned to this schedule. */
sellableSeats: number;
/** Seats with a CONFIRMED/BOARDED booking on this schedule. */
soldSeats: number;
/** `soldSeats / sellableSeats`, as a percentage rounded to one decimal. */
loadFactorPercent: number;
blockedSeatCount: number;
/** Loss at full occupancy — the sum of the fares these seats would have sold for. */
estimatedLossMinor: number;
/** `estimatedLossMinor × loadFactor` — what the train's actual demand supports. */
adjustedLossMinor: number;
currency: string;
blocks: BlockedSeatLossDetail[];
}
/** Loss totals for one currency. */
export interface BlockedSeatLossByCurrency {
currency: string;
estimatedLossMinor: number;
adjustedLossMinor: number;
}
/** Loss grouped by reason category, per currency. */
export interface BlockedSeatLossByReasonCategory {
/** A {@link SeatBlockReasonCategory} value, or {@link UNCATEGORIZED_REASON_CATEGORY}. */
reasonCategory: string;
count: number;
estimatedLossMinor: number;
currency: string;
}
/** Loss grouped by the staff member who blocked the seat, per currency. */
export interface BlockedSeatLossByBlocker {
blockedBy: string;
blockedByName: string;
count: number;
estimatedLossMinor: number;
currency: string;
}
export interface BlockedSeatLossSummary {
schedulesAffected: number;
blockedSeatCount: number;
lossByCurrency: BlockedSeatLossByCurrency[];
topReasonCategories: BlockedSeatLossByReasonCategory[];
topBlockers: BlockedSeatLossByBlocker[];
}
/**
* Provenance for the numbers above. `methodology` and `exclusions` are meant to be
* rendered verbatim in the UI — this report is a counterfactual, and the assumptions
* behind it have to travel with it.
*/
export interface BlockedSeatLossMeta {
/** Total schedules matching the filters, before pagination. */
total: number;
page: number;
pageSize: number;
dateFrom: string;
dateTo: string;
/** Nationality the fares were priced at (drives tariff variant and currency). */
nationalityAssumption: string;
methodology: string;
exclusions: string[];
/** Schedules whose fare could not be computed — their seats count but carry no loss. */
schedulesWithoutFare: number;
}
export interface BlockedSeatRevenueLossReport {
summary: BlockedSeatLossSummary;
schedules: BlockedSeatLossSchedule[];
meta: BlockedSeatLossMeta;
}
/** Compact roll-up embedded in `GET /dashboard/backoffice-stats`. */
export interface BlockedSeatRevenueLossStat {
periodDays: number;
lossByCurrency: BlockedSeatLossByCurrency[];
schedulesAffected: number;
blockedSeatCount: number;
/** Largest category by estimated loss, or `null` when nothing is blocked. */
topReasonCategory: string | null;
}

View File

@@ -1,6 +1,7 @@
import type { BaseEntity } from "../common";
export * from "./support-chat";
export * from "./blocked-seat-revenue-loss";
export enum TicketStatus {
Reserved = "RESERVED",