mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into staging
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +552,19 @@ function NewShipmentBookingForm({
|
||||
marginTop: "auto",
|
||||
}}
|
||||
>
|
||||
<Group justify="flex-end" className="mx-auto max-w-4xl">
|
||||
<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
|
||||
@@ -557,6 +587,7 @@ function NewShipmentBookingForm({
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
<PriceConfirmModal
|
||||
@@ -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,7 +1906,8 @@ function ContainerLineEditor({
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => (
|
||||
{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`}
|
||||
@@ -1926,7 +1977,11 @@ function ContainerLineEditor({
|
||||
checked={Boolean(field.value)}
|
||||
aria-label={`${col.label} — container ${u + 1}`}
|
||||
onChange={(e) =>
|
||||
toggleUnitHandling(u, col.key, e.currentTarget.checked)
|
||||
toggleUnitHandling(
|
||||
u,
|
||||
col.key,
|
||||
e.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -1943,7 +1998,8 @@ function ContainerLineEditor({
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
@@ -1349,12 +1349,29 @@ 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
|
||||
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?
|
||||
@@ -1362,6 +1379,8 @@ model SeatBlock {
|
||||
|
||||
@@index([seatId])
|
||||
@@index([scheduleId])
|
||||
@@index([blockedAt])
|
||||
@@index([scheduleId, seatId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
63
apps/edr-passenger-api/src/common/acting-user.ts
Normal file
63
apps/edr-passenger-api/src/common/acting-user.ts
Normal 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';
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
308
apps/edr-passenger-api/test/available-dates.e2e-spec.ts
Normal file
308
apps/edr-passenger-api/test/available-dates.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)}`,
|
||||
),
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
66
apps/edr-passenger-web/backoffice/src/lib/chart-palette.ts
Normal file
66
apps/edr-passenger-web/backoffice/src/lib/chart-palette.ts
Normal 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;
|
||||
}
|
||||
@@ -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,10 +1321,19 @@ export default function SearchPage() {
|
||||
}
|
||||
maxDate={returnMaxDate}
|
||||
disabledDates={returnDisabledDates}
|
||||
disabled={noReturnRouteForPair}
|
||||
placeholder="Return date"
|
||||
error={!!errors.returnDate}
|
||||
/>
|
||||
</div>
|
||||
{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}
|
||||
@@ -1436,10 +1488,19 @@ export default function SearchPage() {
|
||||
minDate={new Date()}
|
||||
maxDate={departureMaxDate}
|
||||
disabledDates={disabledDates}
|
||||
disabled={noRouteForPair}
|
||||
placeholder="Departure"
|
||||
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}
|
||||
@@ -1592,8 +1653,17 @@ export default function SearchPage() {
|
||||
minDate={new Date()}
|
||||
maxDate={departureMaxDate}
|
||||
disabledDates={disabledDates}
|
||||
disabled={noRouteForPair}
|
||||
placeholder="Departure date"
|
||||
/>
|
||||
{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}
|
||||
@@ -1625,8 +1695,17 @@ export default function SearchPage() {
|
||||
}
|
||||
maxDate={returnMaxDate}
|
||||
disabledDates={returnDisabledDates}
|
||||
disabled={noReturnRouteForPair}
|
||||
placeholder="Return date"
|
||||
/>
|
||||
{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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,6 +301,7 @@ 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>
|
||||
<div data-testid="departure-date-field">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => {
|
||||
@@ -292,9 +311,19 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
minDate={new Date()}
|
||||
maxDate={departureMaxDate}
|
||||
disabledDates={disabledDates}
|
||||
placeholder="Select date"
|
||||
disabled={noRouteForPair}
|
||||
placeholder={noRouteForPair ? 'No route available' : 'Select date'}
|
||||
/>
|
||||
{errors.departureDate && (
|
||||
</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>
|
||||
|
||||
@@ -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
136
e2e-ui/specs/guest/search-date-availability.spec.ts
Normal file
136
e2e-ui/specs/guest/search-date-availability.spec.ts
Normal 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}/);
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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'`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
169
packages/types/src/passenger/blocked-seat-revenue-loss.ts
Normal file
169
packages/types/src/passenger/blocked-seat-revenue-loss.ts
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BaseEntity } from "../common";
|
||||
|
||||
export * from "./support-chat";
|
||||
export * from "./blocked-seat-revenue-loss";
|
||||
|
||||
export enum TicketStatus {
|
||||
Reserved = "RESERVED",
|
||||
|
||||
Reference in New Issue
Block a user