mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
2
.github/workflows/malware-scan.yml
vendored
2
.github/workflows/malware-scan.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
# Plain `self-hosted` — GitHub applies this label to every self-hosted
|
# Plain `self-hosted` — GitHub applies this label to every self-hosted
|
||||||
# runner automatically. The scan is host-agnostic, unlike the deploy jobs
|
# runner automatically. The scan is host-agnostic, unlike the deploy jobs
|
||||||
# which pin to a branch-specific runner.
|
# which pin to a branch-specific runner.
|
||||||
runs-on: self-hosted
|
runs-on: [self-hosted, dev]
|
||||||
outputs:
|
outputs:
|
||||||
infected: ${{ steps.scan.outputs.infected }}
|
infected: ${{ steps.scan.outputs.infected }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -279,7 +279,9 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
lineItems,
|
lineItems,
|
||||||
totalAmount: total,
|
// Grand total is billed in whole currency units — fractional line sums
|
||||||
|
// (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD.
|
||||||
|
totalAmount: Math.round(total),
|
||||||
currency: booking.paymentCurrency,
|
currency: booking.paymentCurrency,
|
||||||
usedRates: [...usedRatesMap.values()],
|
usedRates: [...usedRatesMap.values()],
|
||||||
appliedModifiers: ruleResult.appliedModifiers,
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
|
|||||||
@@ -1074,16 +1074,23 @@ export class ContractsController {
|
|||||||
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
||||||
|
|
||||||
@Post(':id/bookings')
|
@Post(':id/bookings')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
// Path A is a customer flow — both audiences must reach the service, whose
|
||||||
|
// assertGate decides per role. Staff still need contracts:create_booking.
|
||||||
|
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
|
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
|
||||||
})
|
})
|
||||||
createBooking(
|
async createBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: CreateBookingUnderContractDto,
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||||
) {
|
) {
|
||||||
|
// Customer callers may only book on their own contract.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
// The service decides the execution path from the contract:
|
// The service decides the execution path from the contract:
|
||||||
// Path A (customs disabled) → customer/staff create; status checks apply.
|
// Path A (customs disabled) → customer/staff create; status checks apply.
|
||||||
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
|
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
|
||||||
@@ -1096,16 +1103,25 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/bookings/initiate')
|
@Post(':id/bookings/initiate')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
// Customer initiates their own ONE_TIME instance; GL initiates on customs
|
||||||
|
// contracts — the service's assertGate decides per role, so both audiences
|
||||||
|
// must reach it. Staff still need contracts:create_booking.
|
||||||
|
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
|
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
|
||||||
})
|
})
|
||||||
initiateBooking(
|
async initiateBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: CreateBookingUnderContractDto,
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||||
) {
|
) {
|
||||||
|
// Customer callers may only initiate on their own contract; the service's
|
||||||
|
// assertGate then decides what a customer may do on it.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
return this.contractBookingService.initiateUnderContract(
|
return this.contractBookingService.initiateUnderContract(
|
||||||
id,
|
id,
|
||||||
{ contractRouteId: dto?.contractRouteId },
|
{ contractRouteId: dto?.contractRouteId },
|
||||||
@@ -1115,17 +1131,24 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/bookings/:bookingId/complete')
|
@Post(':id/bookings/:bookingId/complete')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
// Customers complete their own initiated (non-customs) instances; the
|
||||||
|
// service keeps customs completion GL-only via the actor's permissions.
|
||||||
|
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
|
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
|
||||||
})
|
})
|
||||||
completeBooking(
|
async completeBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@Body() dto: CreateBookingUnderContractDto,
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||||
) {
|
) {
|
||||||
|
// Customer callers may only complete bookings on their own contract.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
||||||
// service checks the actor's contracts:create_booking permission.
|
// service checks the actor's contracts:create_booking permission.
|
||||||
return this.contractBookingService.completeUnderContract(
|
return this.contractBookingService.completeUnderContract(
|
||||||
|
|||||||
@@ -161,10 +161,10 @@ export default function ClearanceDocumentsPage() {
|
|||||||
<User className="size-4" strokeWidth={1.75} />
|
<User className="size-4" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
{customer}
|
{customer}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<FileText className="size-3 shrink-0 opacity-70" />
|
<FileText className="size-3 shrink-0 opacity-70" />
|
||||||
{b.reference}
|
{b.reference}
|
||||||
</p>
|
</p>
|
||||||
@@ -182,7 +182,7 @@ export default function ClearanceDocumentsPage() {
|
|||||||
<ContractReferenceLink
|
<ContractReferenceLink
|
||||||
contractId={b.contractId}
|
contractId={b.contractId}
|
||||||
contractReference={b.contractReference}
|
contractReference={b.contractReference}
|
||||||
className="block truncate text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
|
className="block text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm">—</Text>
|
<Text size="sm">—</Text>
|
||||||
@@ -409,7 +409,7 @@ export default function ClearanceDocumentsPage() {
|
|||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
pageCount,
|
pageCount,
|
||||||
}}
|
}}
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||||
footer={DataTableFooter}
|
footer={DataTableFooter}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -393,10 +393,10 @@ function ShipmentBookingsTable({
|
|||||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
{row.original.reference}
|
{row.original.reference}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<User className="size-3 shrink-0 opacity-70" />
|
<User className="size-3 shrink-0 opacity-70" />
|
||||||
{row.original.customerLabel}
|
{row.original.customerLabel}
|
||||||
</p>
|
</p>
|
||||||
@@ -413,7 +413,7 @@ function ShipmentBookingsTable({
|
|||||||
<Stack gap={4} py={2}>
|
<Stack gap={4} py={2}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
||||||
<Text size="sm" fw={500} truncate maw={150}>
|
<Text size="sm" fw={500}>
|
||||||
{r.contractReference ?? "—"}
|
{r.contractReference ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -431,13 +431,9 @@ function ShipmentBookingsTable({
|
|||||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Text size="sm" className="truncate">
|
<Text size="sm">{row.original.originLabel}</Text>
|
||||||
{row.original.originLabel}
|
|
||||||
</Text>
|
|
||||||
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
|
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
|
||||||
<Text size="sm" className="truncate">
|
<Text size="sm">{row.original.destinationLabel}</Text>
|
||||||
{row.original.destinationLabel}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -610,7 +606,7 @@ function ShipmentBookingsTable({
|
|||||||
data={rows}
|
data={rows}
|
||||||
status={loading ? "loading" : error ? "error" : "success"}
|
status={loading ? "loading" : error ? "error" : "success"}
|
||||||
onRowClick={(row) => onOpen(row.id)}
|
onRowClick={(row) => onOpen(row.id)}
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,11 +226,11 @@ function RouteCell({
|
|||||||
return (
|
return (
|
||||||
<Stack gap={4} py={2}>
|
<Stack gap={4} py={2}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Text size="sm" fw={500} truncate maw={120}>
|
<Text size="sm" fw={500}>
|
||||||
{origin}
|
{origin}
|
||||||
</Text>
|
</Text>
|
||||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||||
<Text size="sm" fw={500} truncate maw={120}>
|
<Text size="sm" fw={500}>
|
||||||
{destination}
|
{destination}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -385,10 +385,10 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
{r.reference}
|
{r.reference}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<User className="size-3 shrink-0 opacity-70" />
|
<User className="size-3 shrink-0 opacity-70" />
|
||||||
{r.customerLabel}
|
{r.customerLabel}
|
||||||
</p>
|
</p>
|
||||||
@@ -403,9 +403,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
||||||
<Text size="sm" truncate maw={140}>
|
<Text size="sm">{row.original.contractReference}</Text>
|
||||||
{row.original.contractReference}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -710,7 +708,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
pageCount,
|
pageCount,
|
||||||
}}
|
}}
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||||
footer={DataTableFooter}
|
footer={DataTableFooter}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Maximum time (hours) a passenger has to pay after booking. */
|
/** Maximum time (hours) a passenger has to pay after booking. */
|
||||||
export const MAX_PAYMENT_HOURS = 240;
|
export const MAX_PAYMENT_HOURS = 2;
|
||||||
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
||||||
export const CUTOFF_MINUTES = 30;
|
export const CUTOFF_MINUTES = 30;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { DashboardController } from './dashboard.controller';
|
import { DashboardController } from './dashboard.controller';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
import { ReportsModule } from '../reports/reports.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({ controllers: [DashboardController], providers: [DashboardService] })
|
||||||
// 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 {}
|
export class DashboardModule {}
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { BlockedSeatRevenueLossStat } from '@edr/types';
|
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { ReportsService } from '../reports/reports.service';
|
|
||||||
|
|
||||||
/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
|
|
||||||
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
|
|
||||||
periodDays: null,
|
|
||||||
lossByCurrency: [],
|
|
||||||
schedulesAffected: 0,
|
|
||||||
blockedSeatCount: 0,
|
|
||||||
topReasonCategory: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
private readonly logger = new Logger(DashboardService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
@InjectDataSource() private dataSource: DataSource,
|
@InjectDataSource() private dataSource: DataSource,
|
||||||
private reports: ReportsService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getBackofficeStats() {
|
async getBackofficeStats() {
|
||||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows, blockedSeatRevenueLoss] =
|
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
|
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
|
||||||
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
|
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
|
||||||
@@ -52,9 +38,6 @@ export class DashboardService {
|
|||||||
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
|
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
|
||||||
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
|
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({
|
const totalPackageTickets = await this.prisma.ticket.count({
|
||||||
@@ -75,43 +58,11 @@ export class DashboardService {
|
|||||||
totalNormalTickets: totalTickets - totalPackageTickets,
|
totalNormalTickets: totalTickets - totalPackageTickets,
|
||||||
totalPassengers,
|
totalPassengers,
|
||||||
blockedSeatsCount,
|
blockedSeatsCount,
|
||||||
blockedSeatRevenueLoss,
|
|
||||||
revenueByCurrency: toMap(revenueRows),
|
revenueByCurrency: toMap(revenueRows),
|
||||||
packageRevenueByCurrency: toMap(packageRevenueRows),
|
packageRevenueByCurrency: toMap(packageRevenueRows),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact roll-up of the Blocked Seat Revenue Loss report over its full history.
|
|
||||||
*
|
|
||||||
* 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: null,
|
|
||||||
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) {
|
async getHomeDashboard(passengerId: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
||||||
|
|||||||
@@ -193,7 +193,10 @@ function supersedes(candidate: CountedBlock, existing: CountedBlock): boolean {
|
|||||||
return candidate.block.blockedAt.getTime() > existing.block.blockedAt.getTime();
|
return candidate.block.blockedAt.getTime() > existing.block.blockedAt.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGlobalBlockInEffectAt(block: LossBlock, departureAt: Date): boolean {
|
export function isGlobalBlockInEffectAt(
|
||||||
|
block: Pick<LossBlock, 'blockedAt' | 'unblockAt'>,
|
||||||
|
departureAt: Date,
|
||||||
|
): boolean {
|
||||||
if (block.blockedAt.getTime() > departureAt.getTime()) return false;
|
if (block.blockedAt.getTime() > departureAt.getTime()) return false;
|
||||||
if (block.unblockAt === null) return true;
|
if (block.unblockAt === null) return true;
|
||||||
return block.unblockAt.getTime() >= departureAt.getTime();
|
return block.unblockAt.getTime() >= departureAt.getTime();
|
||||||
@@ -210,9 +213,10 @@ export function isPlaceholderSeat(seat: Pick<LossSeat, 'seatNumber'>): boolean {
|
|||||||
* inflates the blocked-seat count. Match on a substring of type *or* name so both the
|
* 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.
|
* documented convention and the data as it actually exists are covered.
|
||||||
*/
|
*/
|
||||||
export function isDiningCoach(
|
export function isDiningCoach(coach: {
|
||||||
coach: Pick<LossCoach, 'coachTypeType' | 'coachTypeName'>,
|
coachTypeType?: string | null;
|
||||||
): boolean {
|
coachTypeName?: string | null;
|
||||||
|
}): boolean {
|
||||||
const haystack = `${coach.coachTypeType ?? ''} ${coach.coachTypeName ?? ''}`.toLowerCase();
|
const haystack = `${coach.coachTypeType ?? ''} ${coach.coachTypeName ?? ''}`.toLowerCase();
|
||||||
return haystack.includes('dining');
|
return haystack.includes('dining');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import {
|
|||||||
} from "./reports.dto";
|
} from "./reports.dto";
|
||||||
import {
|
import {
|
||||||
assembleReport,
|
assembleReport,
|
||||||
|
isDiningCoach,
|
||||||
|
isGlobalBlockInEffectAt,
|
||||||
|
isPlaceholderSeat,
|
||||||
LossCalculatorInput,
|
LossCalculatorInput,
|
||||||
LossCoach,
|
LossCoach,
|
||||||
LossFare,
|
LossFare,
|
||||||
LossSeat,
|
LossSeat,
|
||||||
selectCountedBlocks,
|
selectCountedBlocks,
|
||||||
soldKey,
|
soldKey,
|
||||||
|
TICKETING_BLOCK_REASON_PREFIX,
|
||||||
} from "./blocked-seats-loss.calculator";
|
} from "./blocked-seats-loss.calculator";
|
||||||
|
|
||||||
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
|
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
|
||||||
@@ -528,7 +532,8 @@ export class ReportsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSeatStatusReport(scheduleId: string) {
|
async getSeatStatusReport(scheduleId: string) {
|
||||||
// Confirmed/boarded seats — exclude dining coaches
|
// Confirmed/boarded seats. Dining coaches are dropped in JS below — `CoachType.type`
|
||||||
|
// holds display names in real data, so an exact match here would not catch them.
|
||||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -536,7 +541,6 @@ export class ReportsService {
|
|||||||
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||||
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||||
],
|
],
|
||||||
seat: { coach: { coachType: { type: { not: 'dining' } } } },
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
booking: {
|
booking: {
|
||||||
@@ -565,12 +569,6 @@ export class ReportsService {
|
|||||||
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
|
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Active seat holds for this schedule
|
|
||||||
const activeHolds = await this.prisma.seatHold.findMany({
|
|
||||||
where: { scheduleId },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expired holds (last 24h) — held but never converted to a booking
|
// Expired holds (last 24h) — held but never converted to a booking
|
||||||
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
const expiredHolds = await this.prisma.seatHold.findMany({
|
const expiredHolds = await this.prisma.seatHold.findMany({
|
||||||
@@ -581,35 +579,89 @@ export class ReportsService {
|
|||||||
orderBy: { expiresAt: 'desc' },
|
orderBy: { expiresAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manually blocked seats — schedule-scoped blocks for this schedule OR global blocks (scheduleId null)
|
// Manually blocked seats. Counted the same way the blocked-seat revenue loss report
|
||||||
// Exclude MAINTENANCE and booking-system-created blocks
|
// counts them (see `selectCountedBlocks`), so the two reports never disagree:
|
||||||
const blocks = await this.prisma.seatBlock.findMany({
|
// - a schedule-scoped block naming this schedule, or
|
||||||
where: {
|
// - a global block (scheduleId null) that was in effect at departure AND sits on a
|
||||||
OR: [
|
// coach actually assigned to this train.
|
||||||
{ scheduleId },
|
// A global block on a coach that never joined this consist is not a blocked seat here.
|
||||||
{ scheduleId: null },
|
// Excluded: dining coaches, placeholder seats, ticket-issuance bookkeeping blocks, and
|
||||||
],
|
// MAINTENANCE (a seat out of service, not one withheld by hand).
|
||||||
NOT: [
|
const [schedule, assignments, blockRows] = await Promise.all([
|
||||||
{ reason: { startsWith: 'MAINTENANCE:' } },
|
this.prisma.trainSchedule.findUnique({
|
||||||
{ reason: { startsWith: 'Booked in tickets' } },
|
where: { id: scheduleId },
|
||||||
],
|
select: { departureAt: true },
|
||||||
},
|
}),
|
||||||
include: {
|
this.prisma.coachAssignment.findMany({
|
||||||
seat: {
|
where: { scheduleId },
|
||||||
select: {
|
select: { coachId: true },
|
||||||
seatNumber: true,
|
}),
|
||||||
bedPosition: true,
|
this.prisma.seatBlock.findMany({
|
||||||
coach: {
|
where: {
|
||||||
select: {
|
OR: [
|
||||||
number: true,
|
{ scheduleId },
|
||||||
coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } },
|
{ scheduleId: null },
|
||||||
|
],
|
||||||
|
NOT: [
|
||||||
|
{ reason: { startsWith: 'MAINTENANCE:' } },
|
||||||
|
{ reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seat: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
coachId: true,
|
||||||
|
seatNumber: true,
|
||||||
|
bedPosition: true,
|
||||||
|
coach: {
|
||||||
|
select: {
|
||||||
|
number: true,
|
||||||
|
coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
orderBy: { blockedAt: 'desc' },
|
||||||
orderBy: { blockedAt: 'desc' },
|
}),
|
||||||
});
|
]);
|
||||||
|
|
||||||
|
const assignedCoachIds = new Set(assignments.map((a) => a.coachId));
|
||||||
|
const departureAt = schedule?.departureAt ?? null;
|
||||||
|
|
||||||
|
// One counted block per seat: a schedule-scoped block beats a global one, and between
|
||||||
|
// two of the same kind the most recent wins — the rows arrive newest-first, so the
|
||||||
|
// first of a kind seen for a seat is already the most recent.
|
||||||
|
const countedBySeat = new Map<string, (typeof blockRows)[number]>();
|
||||||
|
for (const block of blockRows) {
|
||||||
|
const seat = block.seat;
|
||||||
|
if (!seat || isPlaceholderSeat(seat)) continue;
|
||||||
|
|
||||||
|
const coachType = seat.coach?.coachType;
|
||||||
|
if (
|
||||||
|
isDiningCoach({
|
||||||
|
coachTypeType: coachType?.type ?? null,
|
||||||
|
coachTypeName: coachType?.name ?? null,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.scheduleId === null) {
|
||||||
|
if (!assignedCoachIds.has(seat.coachId)) continue;
|
||||||
|
if (!departureAt || !isGlobalBlockInEffectAt(block, departureAt)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = countedBySeat.get(seat.id);
|
||||||
|
if (!existing || (existing.scheduleId === null && block.scheduleId !== null)) {
|
||||||
|
countedBySeat.set(seat.id, block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = [...countedBySeat.values()].sort(
|
||||||
|
(a, b) => b.blockedAt.getTime() - a.blockedAt.getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
const resolveSeatClass = (seat: any): string | null => {
|
const resolveSeatClass = (seat: any): string | null => {
|
||||||
const classes = seat?.coach?.coachType?.seatClasses ?? [];
|
const classes = seat?.coach?.coachType?.seatClasses ?? [];
|
||||||
@@ -619,10 +671,18 @@ export class ReportsService {
|
|||||||
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null;
|
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const paidSeats = bookingSeats.filter(bs =>
|
const passengerSeats = bookingSeats.filter(
|
||||||
|
bs =>
|
||||||
|
!isDiningCoach({
|
||||||
|
coachTypeType: bs.seat?.coach?.coachType?.type ?? null,
|
||||||
|
coachTypeName: bs.seat?.coach?.coachType?.name ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const paidSeats = passengerSeats.filter(bs =>
|
||||||
bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED'
|
bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED'
|
||||||
);
|
);
|
||||||
const unpaidSeats = bookingSeats.filter(bs =>
|
const unpaidSeats = passengerSeats.filter(bs =>
|
||||||
bs.booking.status === 'PENDING_PAYMENT'
|
bs.booking.status === 'PENDING_PAYMENT'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -645,7 +705,7 @@ export class ReportsService {
|
|||||||
paidCount: paidSeats.length,
|
paidCount: paidSeats.length,
|
||||||
unpaidCount: unpaidSeats.length,
|
unpaidCount: unpaidSeats.length,
|
||||||
expiredHoldCount: expiredHolds.length,
|
expiredHoldCount: expiredHolds.length,
|
||||||
blockedCount: blocks.filter(b => b.seat?.coach?.coachType?.type !== 'dining').length,
|
blockedCount: blocks.length,
|
||||||
},
|
},
|
||||||
paidSeats: paidSeats.map(mapSeat),
|
paidSeats: paidSeats.map(mapSeat),
|
||||||
unpaidSeats: unpaidSeats.map(mapSeat),
|
unpaidSeats: unpaidSeats.map(mapSeat),
|
||||||
@@ -655,18 +715,16 @@ export class ReportsService {
|
|||||||
expiresAt: h.expiresAt,
|
expiresAt: h.expiresAt,
|
||||||
createdAt: h.createdAt,
|
createdAt: h.createdAt,
|
||||||
})),
|
})),
|
||||||
blockedSeats: blocks
|
blockedSeats: blocks.map(b => ({
|
||||||
.filter(b => b.seat?.coach?.coachType?.type !== 'dining')
|
id: b.id,
|
||||||
.map(b => ({
|
coachNumber: b.seat?.coach?.number ?? null,
|
||||||
id: b.id,
|
seatNumber: b.seat?.seatNumber ?? null,
|
||||||
coachNumber: b.seat?.coach?.number ?? null,
|
seatClassName: resolveSeatClass(b.seat),
|
||||||
seatNumber: b.seat?.seatNumber ?? null,
|
reason: b.reason,
|
||||||
seatClassName: resolveSeatClass(b.seat),
|
blockedBy: b.blockedBy,
|
||||||
reason: b.reason,
|
blockedAt: b.blockedAt,
|
||||||
blockedBy: b.blockedBy,
|
unblockAt: b.unblockAt,
|
||||||
blockedAt: b.blockedAt,
|
})),
|
||||||
unblockAt: b.unblockAt,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the blocked-seat half of the seat status report.
|
||||||
|
*
|
||||||
|
* The count used to be a raw `SeatBlock` row count with an exact `type === 'dining'`
|
||||||
|
* exclusion. Real EDR data stores display names in `CoachType.type` ('Dining Coach '),
|
||||||
|
* so dining seats slipped through, and every global block counted even when its coach
|
||||||
|
* never joined the train. These cases pin the corrected rule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCHEDULE_ID = 'sched-1';
|
||||||
|
const DEPARTURE = new Date('2026-03-10T06:00:00.000Z');
|
||||||
|
|
||||||
|
interface CoachSpec {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
typeType?: string;
|
||||||
|
typeName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passengerCoach: CoachSpec = { id: 'coach-1', number: 'C1' };
|
||||||
|
const diningCoach: CoachSpec = {
|
||||||
|
id: 'coach-dining',
|
||||||
|
number: 'D1',
|
||||||
|
// As the data actually looks: display name in `type`, trailing space included.
|
||||||
|
typeType: 'Dining Coach ',
|
||||||
|
typeName: 'Dining Coach',
|
||||||
|
};
|
||||||
|
|
||||||
|
function seatRow(
|
||||||
|
id: string,
|
||||||
|
coach: CoachSpec,
|
||||||
|
seatNumber: string,
|
||||||
|
bedPosition: string | null = null,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
coachId: coach.id,
|
||||||
|
seatNumber,
|
||||||
|
bedPosition,
|
||||||
|
coach: {
|
||||||
|
number: coach.number,
|
||||||
|
coachType: {
|
||||||
|
name: coach.typeName ?? 'Standard',
|
||||||
|
type: coach.typeType ?? 'passenger',
|
||||||
|
seatClasses: [{ name: 'Economy', bedPosition: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockRow(
|
||||||
|
overrides: Partial<{
|
||||||
|
id: string;
|
||||||
|
scheduleId: string | null;
|
||||||
|
reason: string;
|
||||||
|
blockedAt: Date;
|
||||||
|
unblockAt: Date | null;
|
||||||
|
seat: ReturnType<typeof seatRow>;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: 'block-1',
|
||||||
|
scheduleId: SCHEDULE_ID as string | null,
|
||||||
|
reason: 'VIP hold',
|
||||||
|
blockedBy: 'user-1',
|
||||||
|
blockedAt: new Date('2026-03-01T00:00:00.000Z'),
|
||||||
|
unblockAt: null as Date | null,
|
||||||
|
seat: seatRow('seat-1', passengerCoach, '1'),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(opts: {
|
||||||
|
blocks: ReturnType<typeof blockRow>[];
|
||||||
|
assignedCoachIds?: string[];
|
||||||
|
departureAt?: Date | null;
|
||||||
|
bookingSeats?: any[];
|
||||||
|
}) {
|
||||||
|
const prisma = {
|
||||||
|
bookingSeat: { findMany: jest.fn().mockResolvedValue(opts.bookingSeats ?? []) },
|
||||||
|
seatHold: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
trainSchedule: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(
|
||||||
|
opts.departureAt === null ? null : { departureAt: opts.departureAt ?? DEPARTURE },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
coachAssignment: {
|
||||||
|
findMany: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
(opts.assignedCoachIds ?? [passengerCoach.id, diningCoach.id]).map((coachId) => ({
|
||||||
|
coachId,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
seatBlock: { findMany: jest.fn().mockResolvedValue(opts.blocks) },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new ReportsService(prisma as any, {} as any, {} as any),
|
||||||
|
prisma,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getSeatStatusReport — blocked seats', () => {
|
||||||
|
it('counts a schedule-scoped block on a passenger coach', async () => {
|
||||||
|
const { service } = makeService({ blocks: [blockRow()] });
|
||||||
|
|
||||||
|
const report = await service.getSeatStatusReport(SCHEDULE_ID);
|
||||||
|
|
||||||
|
expect(report.summary.blockedCount).toBe(1);
|
||||||
|
expect(report.blockedSeats).toHaveLength(1);
|
||||||
|
expect(report.blockedSeats[0]).toMatchObject({ coachNumber: 'C1', seatNumber: '1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out a dining coach whose type carries a display name', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
blocks: [blockRow({ seat: seatRow('seat-d', diningCoach, '1') })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await service.getSeatStatusReport(SCHEDULE_ID);
|
||||||
|
|
||||||
|
expect(report.summary.blockedCount).toBe(0);
|
||||||
|
expect(report.blockedSeats).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out placeholder seats', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
blocks: [blockRow({ seat: seatRow('seat-p', passengerCoach, '-1') })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a global block on a coach assigned to this train', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
blocks: [blockRow({ scheduleId: null })],
|
||||||
|
assignedCoachIds: [passengerCoach.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a global block whose coach never joined this train', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
blocks: [blockRow({ scheduleId: null })],
|
||||||
|
assignedCoachIds: ['some-other-coach'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a global block that had already been lifted by departure', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
blocks: [
|
||||||
|
blockRow({
|
||||||
|
scheduleId: null,
|
||||||
|
unblockAt: new Date('2026-03-05T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
assignedCoachIds: [passengerCoach.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a global block created after departure', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
blocks: [blockRow({ scheduleId: null, blockedAt: new Date('2026-03-20T00:00:00.000Z') })],
|
||||||
|
assignedCoachIds: [passengerCoach.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a seat blocked both globally and for this schedule once', async () => {
|
||||||
|
const seat = seatRow('seat-1', passengerCoach, '1');
|
||||||
|
const { service } = makeService({
|
||||||
|
blocks: [
|
||||||
|
blockRow({ id: 'block-schedule', seat, reason: 'Crew seat' }),
|
||||||
|
blockRow({ id: 'block-global', scheduleId: null, seat, reason: 'Broken armrest' }),
|
||||||
|
],
|
||||||
|
assignedCoachIds: [passengerCoach.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await service.getSeatStatusReport(SCHEDULE_ID);
|
||||||
|
|
||||||
|
expect(report.summary.blockedCount).toBe(1);
|
||||||
|
// The schedule-scoped block is the more specific statement, so it is the one shown.
|
||||||
|
expect(report.blockedSeats[0].reason).toBe('Crew seat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nothing blocked when the schedule does not exist', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
blocks: [blockRow({ scheduleId: null })],
|
||||||
|
departureAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks the database only for non-maintenance, non-ticketing blocks', async () => {
|
||||||
|
const { service, prisma } = makeService({ blocks: [] });
|
||||||
|
|
||||||
|
await service.getSeatStatusReport(SCHEDULE_ID);
|
||||||
|
|
||||||
|
const where = prisma.seatBlock.findMany.mock.calls[0][0].where;
|
||||||
|
expect(where.NOT).toEqual([
|
||||||
|
{ reason: { startsWith: 'MAINTENANCE:' } },
|
||||||
|
{ reason: { startsWith: 'Booked in tickets' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSeatStatusReport — booked seats', () => {
|
||||||
|
const booking = {
|
||||||
|
bookingRef: 'BK-1',
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
totalMinor: 20000,
|
||||||
|
currency: 'ETB',
|
||||||
|
createdAt: new Date('2026-03-01T00:00:00.000Z'),
|
||||||
|
paymentIntent: { status: 'SUCCEEDED' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps dining-coach seats out of the paid and unpaid counts', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
blocks: [],
|
||||||
|
bookingSeats: [
|
||||||
|
{
|
||||||
|
passengerName: 'Abebe',
|
||||||
|
passengerCategory: 'ADULT',
|
||||||
|
fareMinor: 20000,
|
||||||
|
booking,
|
||||||
|
seat: seatRow('seat-1', passengerCoach, '1'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
passengerName: 'Diner',
|
||||||
|
passengerCategory: 'ADULT',
|
||||||
|
fareMinor: 0,
|
||||||
|
booking,
|
||||||
|
seat: seatRow('seat-d', diningCoach, '1'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
passengerName: 'Kebede',
|
||||||
|
passengerCategory: 'ADULT',
|
||||||
|
fareMinor: 20000,
|
||||||
|
booking: { ...booking, status: 'PENDING_PAYMENT', paymentIntent: null },
|
||||||
|
seat: seatRow('seat-2', passengerCoach, '2'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await service.getSeatStatusReport(SCHEDULE_ID);
|
||||||
|
|
||||||
|
expect(report.summary.paidCount).toBe(1);
|
||||||
|
expect(report.summary.unpaidCount).toBe(1);
|
||||||
|
expect(report.paidSeats.map((s) => s.passengerName)).toEqual(['Abebe']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,9 +10,7 @@ import {
|
|||||||
Banknote,
|
Banknote,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ScanLine,
|
ScanLine,
|
||||||
Ban,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { SEAT_BLOCK_REASON_CATEGORY_LABELS } from "@edr/types";
|
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { formatCurrency } from "@/lib/utils";
|
import { formatCurrency } from "@/lib/utils";
|
||||||
@@ -163,10 +161,6 @@ function DashboardPageContent() {
|
|||||||
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
|
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
|
||||||
}, 0);
|
}, 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 normalRows = stats?.revenueByCurrency ?? [];
|
||||||
const packageRows = stats?.packageRevenueByCurrency ?? [];
|
const packageRows = stats?.packageRevenueByCurrency ?? [];
|
||||||
const normalGrand = calcGrand(normalRows);
|
const normalGrand = calcGrand(normalRows);
|
||||||
@@ -326,73 +320,6 @@ function DashboardPageContent() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 border-rose-200 bg-rose-50/60 dark:border-rose-900/50 dark:bg-rose-950/20">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="rounded-lg bg-rose-100 dark:bg-rose-900/40 p-1.5">
|
|
||||||
<Ban className="h-4 w-4 text-rose-600 dark:text-rose-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-rose-700 dark:text-rose-400">
|
|
||||||
Blocked Seats / Revenue Not Collected
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-[11px] text-muted-foreground">
|
|
||||||
{blockedLoss?.periodDays ? `Last ${blockedLoss.periodDays}d` : "All time"}
|
|
||||||
</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-rose-600 dark:text-rose-400 tabular-nums"
|
|
||||||
: "text-lg font-semibold text-rose-600/80 dark:text-rose-400/80 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 justify-center gap-1.5 rounded-md bg-rose-600 hover:bg-rose-700 dark:bg-rose-600 dark:hover:bg-rose-500 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors mt-auto"
|
|
||||||
>
|
|
||||||
View full report <ArrowRight className="h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue breakdown */}
|
{/* Revenue breakdown */}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import type { BlockedSeatRevenueLossStat } from '@edr/types';
|
|
||||||
import { DashboardStats, RevenueData } from '@/types';
|
import { DashboardStats, RevenueData } from '@/types';
|
||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
@@ -13,7 +12,6 @@ export const dashboardApi = {
|
|||||||
totalPackageTickets: number;
|
totalPackageTickets: number;
|
||||||
totalPassengers: number;
|
totalPassengers: number;
|
||||||
blockedSeatsCount: number;
|
blockedSeatsCount: number;
|
||||||
blockedSeatRevenueLoss: BlockedSeatRevenueLossStat;
|
|
||||||
revenueByCurrency: { currency: string; totalMinor: number }[];
|
revenueByCurrency: { currency: string; totalMinor: number }[];
|
||||||
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
|
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
|
||||||
}>('/dashboard/backoffice-stats');
|
}>('/dashboard/backoffice-stats');
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { amountsMatchToTheCent } from "./cbe-bill.service";
|
||||||
|
|
||||||
|
describe("amountsMatchToTheCent (CBE payment amount gate)", () => {
|
||||||
|
it("accepts the exact amount", () => {
|
||||||
|
expect(amountsMatchToTheCent(1234.34, 1234.34)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a cents-only difference (the 1234.89 vs 1234.34 bug)", () => {
|
||||||
|
expect(amountsMatchToTheCent(1234.89, 1234.34)).toBe(false);
|
||||||
|
expect(amountsMatchToTheCent(1234.35, 1234.34)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects whole-unit differences", () => {
|
||||||
|
expect(amountsMatchToTheCent(1235.34, 1234.34)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absorbs double-precision storage noise", () => {
|
||||||
|
expect(amountsMatchToTheCent(1234.34, 1234.3399999999999)).toBe(true);
|
||||||
|
// classic float artifact: 0.1 + 0.2 !== 0.3
|
||||||
|
expect(amountsMatchToTheCent(0.1 + 0.2, 0.3)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,8 +38,15 @@ import {
|
|||||||
/** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */
|
/** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */
|
||||||
const PG_UNIQUE_VIOLATION = "23505";
|
const PG_UNIQUE_VIOLATION = "23505";
|
||||||
|
|
||||||
/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */
|
/**
|
||||||
const AMOUNT_TOLERANCE = 0.01;
|
* CBE must pay the bill to the exact cent — compare in integer cents so
|
||||||
|
* double-precision storage noise (1234.34 stored as 1234.33999…) can neither
|
||||||
|
* mask nor fabricate a difference. A relative tolerance is wrong here: 1% of a
|
||||||
|
* 1234.34 bill would wave through anything up to ±12.34.
|
||||||
|
*/
|
||||||
|
export function amountsMatchToTheCent(a: number, b: number): boolean {
|
||||||
|
return Math.round(a * 100) === Math.round(b * 100);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate an intent's own terminal state into the same reason vocabulary the domain apps
|
* Translate an intent's own terminal state into the same reason vocabulary the domain apps
|
||||||
@@ -265,11 +272,15 @@ export class CbeBillService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate against the freshly-quoted amount — the same figure bill-query
|
||||||
|
// just showed the payer — not the intent's amount asserted at creation,
|
||||||
|
// which can go stale when the domain re-prices the invoice. Fallback to
|
||||||
|
// the intent amount only for domain builds that return no current amount.
|
||||||
|
const expectedAmount = billQuery.currentAmountMinor ?? intent.amountMinor;
|
||||||
const amount = Number(dto.Amount);
|
const amount = Number(dto.Amount);
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(amount) ||
|
!Number.isFinite(amount) ||
|
||||||
Math.abs(amount - intent.amountMinor) >
|
!amountsMatchToTheCent(amount, expectedAmount)
|
||||||
intent.amountMinor * AMOUNT_TOLERANCE
|
|
||||||
) {
|
) {
|
||||||
throw new CbeBillError("Amount mismatch", "BUSINESS");
|
throw new CbeBillError("Amount mismatch", "BUSINESS");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#
|
#
|
||||||
# Build: DOCKER_BUILDKIT=1 docker compose build
|
# Build: DOCKER_BUILDKIT=1 docker compose build
|
||||||
# Run: docker compose up -d
|
# Run: docker compose up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
freight-api:
|
freight-api:
|
||||||
build:
|
build:
|
||||||
|
|||||||
Reference in New Issue
Block a user