This commit is contained in:
Marshal
2026-06-16 14:17:40 +00:00
parent 524d3b6418
commit 2d0f5ce6ee
14 changed files with 235 additions and 19 deletions

View File

@@ -16,7 +16,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
statuses: readonly string[] | null; statuses: readonly string[] | null;
}> = [ }> = [
{ key: 'all', statuses: null }, { key: 'all', statuses: null },
{ key: 'intake', statuses: ['SUBMITTED'] }, { key: 'intake', statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'] },
{ {
key: 'in_approval', key: 'in_approval',
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
{ key: 'payment', statuses: ['FULLY_EXECUTED'] }, { key: 'payment', statuses: ['FULLY_EXECUTED'] },
{ {
key: 'operations', key: 'operations',
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'], statuses: ['IN_TRANSIT', 'PAID'],
}, },
{ key: 'completed', statuses: ['COMPLETED'] }, { key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },

View File

@@ -1,4 +1,10 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
@@ -184,6 +190,16 @@ export class BookingTransitionService {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']); assertBookingStatus(booking, ['SUBMITTED']);
// Consolidation gate: a booking whose containers don't fill whole wagons
// cannot be accepted until it is paired with a complementary booking.
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
if (gate.blocked) {
throw new ConflictException(
gate.message ??
'Booking requires consolidation and cannot be accepted until a partner is found.',
);
}
await this.ruleEngineService.instantiateApprovalSteps(bookingId, { await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK', freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId, cargoTypeId: booking.cargoTypeId,

View File

@@ -93,6 +93,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots') .leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers') .leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
.where('booking.id = :id', { id }) .where('booking.id = :id', { id })
.leftJoinAndMapMany( .leftJoinAndMapMany(
'booking.files', 'booking.files',
@@ -177,7 +178,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.andWhere('b.allowConsolidation = true') .andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL') .andWhere('b.consolidationPartnerId IS NULL')
.andWhere('b.status IN (:...statuses)', { .andWhere('b.status IN (:...statuses)', {
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'], statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
}) })
.andWhere('b.originYardId = :originYardId', { .andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId, originYardId: booking.originYardId,
@@ -214,15 +215,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
return null; return null;
} }
/** Pair two bookings for consolidation. */ /**
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
* accept them into the approval chain; the link itself (consolidationPartnerId)
* marks them as consolidated in the UI.
*/
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> { async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, { await this.repository.update(bookingId, {
consolidationPartnerId: partnerId, consolidationPartnerId: partnerId,
status: 'CONSOLIDATED', status: 'SUBMITTED',
} as never); } as never);
await this.repository.update(partnerId, { await this.repository.update(partnerId, {
consolidationPartnerId: bookingId, consolidationPartnerId: bookingId,
status: 'CONSOLIDATED', status: 'SUBMITTED',
} as never);
}
/** Park a booking that needs consolidation but has no partner yet. */
async parkForConsolidation(bookingId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: 'PENDING_CONSOLIDATION',
} as never); } as never);
} }
@@ -429,6 +442,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType') .leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
.where('booking.deleted_at IS NULL'); .where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options); this.applyListFilters(qb, options);

View File

@@ -204,6 +204,48 @@ export class BookingsService {
return { booking: pending, messages }; return { booking: pending, messages };
} }
/**
* Consolidation gate used at staff-accept time. Returns the (possibly newly
* paired) booking plus whether it still needs a consolidation partner.
* When a booking needs consolidation and none is found, it is parked in
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
*/
async resolveConsolidationGate(bookingId: string): Promise<{
booking: Booking;
blocked: boolean;
message?: string;
}> {
let booking = await this.findById(bookingId);
// Already paired — passes the gate.
if (booking.consolidationPartnerId) {
return { booking, blocked: false };
}
const needs =
await this.consolidationService.needsConsolidationFromBooking(booking);
if (!needs) {
return { booking, blocked: false };
}
// A partner may have appeared since submission — try to pair now.
const result = await this.tryAutoConsolidate(booking);
booking = result.booking;
if (booking.consolidationPartnerId) {
return { booking, blocked: false, message: result.messages.join(' ') };
}
// Still no partner — park it and block the accept.
await this.bookingsRepository.parkForConsolidation(booking.id);
booking = await this.findById(booking.id);
const slots = await this.consolidationService.slotsFromBooking(booking);
return {
booking,
blocked: true,
message: this.consolidationService.describePending(booking, slots),
};
}
/** Create a new freight booking. */ /** Create a new freight booking. */
async create( async create(
dto: CreateBookingDto, dto: CreateBookingDto,

View File

@@ -1,4 +1,5 @@
import { Badge } from "@mantine/core"; import { Badge, Group } from "@mantine/core";
import { Link2 } from "lucide-react";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config"; import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
const statusColorMap: Record<string, string> = { const statusColorMap: Record<string, string> = {
@@ -24,14 +25,26 @@ const statusColorMap: Record<string, string> = {
CONSOLIDATED: "indigo", CONSOLIDATED: "indigo",
}; };
export function BookingStatusBadge({ status }: { status: string }) { interface BookingStatusBadgeProps {
status: string;
/** When the booking is part of a consolidation, show a sibling badge. */
consolidated?: boolean;
/** Partner booking reference for the consolidated badge tooltip. */
partnerReference?: string | null;
}
export function BookingStatusBadge({
status,
consolidated,
partnerReference,
}: BookingStatusBadgeProps) {
const style = BOOKING_STATUS_STYLES[status] ?? { const style = BOOKING_STATUS_STYLES[status] ?? {
label: status, label: status,
color: "gray", color: "gray",
}; };
const color = statusColorMap[status] ?? "gray"; const color = statusColorMap[status] ?? "gray";
return ( const statusBadge = (
<Badge <Badge
color={color} color={color}
variant="light" variant="light"
@@ -51,4 +64,34 @@ export function BookingStatusBadge({ status }: { status: string }) {
{style.label} {style.label}
</Badge> </Badge>
); );
if (!consolidated) return statusBadge;
return (
<Group gap={4} wrap="nowrap">
{statusBadge}
<Badge
color="indigo"
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
leftSection={<Link2 size={12} />}
title={
partnerReference
? `Consolidated with ${partnerReference}`
: "Part of a consolidation"
}
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
whiteSpace: "nowrap",
}}
>
Consolidated
</Badge>
</Group>
);
} }

View File

@@ -36,7 +36,11 @@ export function BookingDetailHeader({
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}> <Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference} {booking.reference}
</Title> </Title>
<BookingStatusBadge status={booking.status} /> <BookingStatusBadge
status={booking.status}
consolidated={Boolean(booking.consolidationPartnerId)}
partnerReference={booking.consolidationPartner?.reference}
/>
</Group> </Group>
<Group gap="xs"> <Group gap="xs">
<Building2 size={14} color="var(--mantine-color-gray-5)" /> <Building2 size={14} color="var(--mantine-color-gray-5)" />

View File

@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, List, Text } from "@mantine/core";
import { Link2 } from "lucide-react";
import { bookingsService } from "@/services/bookings.service";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
/**
* Shown on a booking parked in PENDING_CONSOLIDATION. Explains that the booking
* cannot be approved until a complementary booking fills the wagon, and lists
* the partial-wagon container lines that are waiting for a partner.
*/
export function ConsolidationWaitingBanner({ bookingId }: { bookingId: string }) {
const { data } = useQuery({
queryKey: [...QUERY_KEYS.BOOKINGS.byId(bookingId), "consolidation"],
queryFn: () => bookingsService.getConsolidationDetails(bookingId),
enabled: Boolean(bookingId),
});
return (
<Alert
color="yellow"
variant="light"
icon={<Link2 size={18} />}
title="Waiting for a consolidation partner"
>
<Text size="sm">
This booking cannot be approved until a matching booking fills the
wagon. It will return to the approval queue automatically once a partner
is found.
</Text>
{data?.wagonSlots?.length ? (
<List size="sm" mt="xs" spacing={2}>
{data.wagonSlots.map((slot) => (
<List.Item key={slot.containerTypeCode}>
{slot.slotsNeeded} more × {slot.containerTypeCode} (
{slot.containersPerWagon} per wagon; you have {slot.quantity})
</List.Item>
))}
</List>
) : null}
</Alert>
);
}

View File

@@ -347,6 +347,10 @@ export function getBookingActions(
case "CHANGES_REQUESTED": case "CHANGES_REQUESTED":
actions = [CANCEL_ACTION]; actions = [CANCEL_ACTION];
break; break;
case "PENDING_CONSOLIDATION":
// View-only while waiting for a consolidation partner; cancel still allowed.
actions = [CANCEL_ACTION];
break;
default: default:
actions = []; actions = [];
} }

View File

@@ -212,21 +212,25 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
}, },
PENDING_CONSOLIDATION: { PENDING_CONSOLIDATION: {
title: "Pending Consolidation", title: "Pending Consolidation",
description: "Waiting for consolidation partner.", description: "Blocked — waiting for a consolidation partner before approval.",
color: "text-amber-600", color: "text-amber-600",
stage: 4, stage: 0,
}, },
CONSOLIDATED: { CONSOLIDATED: {
title: "Consolidated", title: "Consolidated",
description: "Paired with another booking.", description: "Paired with another booking.",
color: "text-indigo-600", color: "text-indigo-600",
stage: 4, stage: 0,
}, },
}; };
export const BOOKING_LIST_TABS = [ export const BOOKING_LIST_TABS = [
{ key: "all", label: "All bookings", statuses: null as string[] | null }, { key: "all", label: "All bookings", statuses: null as string[] | null },
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] }, {
key: "intake",
label: "Submitted",
statuses: ["SUBMITTED", "PENDING_CONSOLIDATION"],
},
{ {
key: "in_approval", key: "in_approval",
label: "In approval", label: "In approval",
@@ -256,7 +260,7 @@ export const BOOKING_LIST_TABS = [
{ {
key: "operations", key: "operations",
label: "Operations", label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"], statuses: ["PAID", "IN_TRANSIT"],
}, },
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] }, { key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] }, { key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
@@ -265,7 +269,15 @@ export const BOOKING_LIST_TABS = [
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"]; export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
export const WORKFLOW_STAGES = [ export const WORKFLOW_STAGES = [
{ label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] }, {
label: "Submission",
statuses: [
"DRAFT",
"SUBMITTED",
"CHANGES_REQUESTED",
"PENDING_CONSOLIDATION",
],
},
{ {
label: "Approval", label: "Approval",
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"], statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
@@ -286,7 +298,7 @@ export const WORKFLOW_STAGES = [
}, },
{ {
label: "Operations", label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"], statuses: ["PAID", "IN_TRANSIT"],
}, },
{ label: "Done", statuses: ["COMPLETED"] }, { label: "Done", statuses: ["COMPLETED"] },
] as const; ] as const;

View File

@@ -42,6 +42,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
trainScheduleId: booking.trainScheduleId ?? null, trainScheduleId: booking.trainScheduleId ?? null,
isGovernment: booking.isGovernment ?? false, isGovernment: booking.isGovernment ?? false,
governmentInstitution: booking.governmentInstitution ?? null, governmentInstitution: booking.governmentInstitution ?? null,
consolidationPartnerId: booking.consolidationPartnerId ?? null,
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
createdAt: booking.createdAt, createdAt: booking.createdAt,
}; };
} }

View File

@@ -17,6 +17,7 @@ import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary"; import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper"; import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import { import {
detailStyles, detailStyles,
BookingRequestHero, BookingRequestHero,
@@ -128,6 +129,10 @@ export default function BookingRequestDetailPage() {
titleColor={statusMeta.color} titleColor={statusMeta.color}
/> />
{booking.status === "PENDING_CONSOLIDATION" && (
<ConsolidationWaitingBanner bookingId={booking.id} />
)}
<Grid gutter="lg"> <Grid gutter="lg">
{/* LEFT — primary content */} {/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 8 }}>

View File

@@ -227,7 +227,11 @@ export default function BookingRequestsPage() {
header: () => <span className={bookingTable.headerCell}>Status</span>, header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="py-1"> <div className="py-1">
<BookingStatusBadge status={row.original.status} /> <BookingStatusBadge
status={row.original.status}
consolidated={Boolean(row.original.consolidationPartnerId)}
partnerReference={row.original.consolidationPartnerReference}
/>
</div> </div>
), ),
meta: { meta: {

View File

@@ -87,6 +87,21 @@ export interface ContractView {
} | null; } | null;
} }
export interface ConsolidationWagonSlot {
containerTypeCode: string;
quantity: number;
containersPerWagon: number;
remainder: number;
slotsNeeded: number;
}
export interface ConsolidationDetails {
statusMessage: string;
wagonSlots: ConsolidationWagonSlot[];
partner: { id: string; reference: string } | null;
splitBilling: { bookingShare: number; partnerShare: number } | null;
}
export interface SignContractPayload { export interface SignContractPayload {
role: "CUSTOMER" | "STAFF"; role: "CUSTOMER" | "STAFF";
signatureImageBase64: string; signatureImageBase64: string;
@@ -198,6 +213,13 @@ export const bookingsService = {
return unwrap(response.data); return unwrap(response.data);
}, },
getConsolidationDetails: async (
id: string,
): Promise<ConsolidationDetails> => {
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
return unwrap(response.data) as ConsolidationDetails;
},
customerSign: (id: string, payload: SignContractPayload) => customerSign: (id: string, payload: SignContractPayload) =>
postBooking<BookingDetail>(B.CUSTOMER_SIGN(id), { postBooking<BookingDetail>(B.CUSTOMER_SIGN(id), {
...payload, ...payload,

View File

@@ -101,6 +101,8 @@ export interface BookingDetail {
cargoTotalWeightVgm: number; cargoTotalWeightVgm: number;
isHazardous: boolean; isHazardous: boolean;
allowConsolidation: boolean; allowConsolidation: boolean;
consolidationPartnerId?: string | null;
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
priorityScore: number; priorityScore: number;
schedulingStatus?: string; schedulingStatus?: string;
holdExpiresAt?: string | null; holdExpiresAt?: string | null;
@@ -157,5 +159,7 @@ export interface BookingListRow {
trainScheduleId?: string | null; trainScheduleId?: string | null;
isGovernment?: boolean; isGovernment?: boolean;
governmentInstitution?: string | null; governmentInstitution?: string | null;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
createdAt: string; createdAt: string;
} }