implement self-healing for freight payment milestones and enhance contract change request visibility

This commit is contained in:
Marshal
2026-07-07 07:45:59 +00:00
parent 05b13e84a8
commit 05d71eb6da
8 changed files with 124 additions and 9 deletions

View File

@@ -176,7 +176,28 @@ export class BookingClearanceService {
} }
const allApproved = await this.isClearanceFullyApproved(booking); const allApproved = await this.isClearanceFullyApproved(booking);
const milestones = await this.workflowService.listMilestonesForBooking(bookingId); let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. Backfill it here so already-stuck
// rows recover without a migration; idempotent (no-op once COMPLETED).
const paymentSettled = milestones.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (
paymentSettled &&
paymentSettled.status === 'PENDING' &&
(booking.paymentStatus === 'PAID' || booking.status === 'PAID')
) {
await this.workflowService.completeMilestoneForBooking(
bookingId,
'FREIGHT_PAYMENT_SETTLED',
);
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones); const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones); const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
const boundary = await this.workflowService.isBoundaryCompleteForBooking( const boundary = await this.workflowService.isBoundaryCompleteForBooking(

View File

@@ -6,6 +6,7 @@ import {
CustomsRiskLevel, CustomsRiskLevel,
MilestoneMetadata, MilestoneMetadata,
} from './entities/clearance-milestone.entity'; } from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { import {
HANDOFF_MILESTONES, HANDOFF_MILESTONES,
@@ -85,10 +86,36 @@ export class ClearanceMilestoneService {
} }
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> { async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
return this.repo.find({ const rows = await this.repo.find({
where: { bookingId }, where: { bookingId },
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },
}); });
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. getClearanceView backfills it, but
// the stepper reads its gating milestones straight from here, so heal here too.
// Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration.
const paymentSettled = rows.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (paymentSettled && paymentSettled.status === 'PENDING') {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, status: true, paymentStatus: true },
});
if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') {
await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED');
return this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
}
}
return rows;
} }
/** /**

View File

@@ -567,6 +567,21 @@ export class ContractsService {
); );
} }
// Surface the staff "request changes" note so the portal can show the
// customer what to fix. Degrade to null on lookup failure — a missing note
// must never 500 a contract fetch.
if (contract.status === 'CHANGES_REQUESTED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'CHANGES_REQUESTED',
);
contract.latestChangeRequestNote = note?.body ?? null;
} catch {
contract.latestChangeRequestNote = null;
}
}
return contract; return contract;
} }

View File

@@ -260,4 +260,11 @@ export class Contract extends BaseEntity {
* ContractsRepository.attachClearancePhases for list responses. Not a column. * ContractsRepository.attachClearancePhases for list responses. Not a column.
*/ */
clearancePhase?: string | null; clearancePhase?: string | null;
/**
* Body of the most recent CHANGES_REQUESTED review note, attached by
* ContractsService.findById so the portal can show the customer what staff
* asked them to fix. Lives in contract_review_notes, not a column here.
*/
latestChangeRequestNote?: string | null;
} }

View File

@@ -382,6 +382,18 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log( this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
); );
} else {
// Already linked at booking time (export FCFS: the customer books a
// specific train, so allocate() ran up front). allocate() is where the
// payment-settled tracking milestones are written, so on this branch we
// record them here — otherwise a paid, already-linked booking leaves
// FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks.
void this.completeTrackingMilestones(bookingId, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
]);
void this.markWagonAllocatedMilestone(bookingId);
} }
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(

View File

@@ -2,6 +2,7 @@ import { Fragment, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Badge,
Box, Box,
Button, Button,
Center, Center,
@@ -339,6 +340,7 @@ export default function ContractsList() {
<Table.Tr> <Table.Tr>
<Table.Th style={{ width: 44 }} aria-label="Expand" /> <Table.Th style={{ width: 44 }} aria-label="Expand" />
<Table.Th>Contract</Table.Th> <Table.Th>Contract</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Cargo</Table.Th> <Table.Th>Cargo</Table.Th>
<Table.Th>Route</Table.Th> <Table.Th>Route</Table.Th>
<Table.Th>Trade</Table.Th> <Table.Th>Trade</Table.Th>
@@ -352,7 +354,7 @@ export default function ContractsList() {
<Table.Tbody> <Table.Tbody>
{isLoading && ( {isLoading && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={10}> <Table.Td colSpan={11}>
<Center py={48}> <Center py={48}>
<Loader color="edr-green" size="sm" /> <Loader color="edr-green" size="sm" />
</Center> </Center>
@@ -362,7 +364,7 @@ export default function ContractsList() {
{!isLoading && isError && ( {!isLoading && isError && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={10}> <Table.Td colSpan={11}>
<Center py={48}> <Center py={48}>
<Text fz={13} c="red"> <Text fz={13} c="red">
Failed to load contracts. Please try again. Failed to load contracts. Please try again.
@@ -374,7 +376,7 @@ export default function ContractsList() {
{!isLoading && !isError && rows.length === 0 && ( {!isLoading && !isError && rows.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={10}> <Table.Td colSpan={11}>
<Stack align="center" gap={8} py={48}> <Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} /> <Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed"> <Text fz={13} c="dimmed">
@@ -442,10 +444,18 @@ export default function ContractsList() {
{c.reference} {c.reference}
</Text> </Text>
<Text fz={12} c="dimmed"> <Text fz={12} c="dimmed">
{isGeneral ? "General" : "One-Time"} ·{" "}
{isContainer ? "Containerised" : "Bulk"} {isContainer ? "Containerised" : "Bulk"}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td>
<Badge
variant="light"
color={isGeneral ? "edr-green" : "gray"}
radius="sm"
>
{isGeneral ? "General" : "One-Time"}
</Badge>
</Table.Td>
<Table.Td> <Table.Td>
<Group gap={7} wrap="nowrap" align="center"> <Group gap={7} wrap="nowrap" align="center">
{isContainer ? ( {isContainer ? (
@@ -533,7 +543,7 @@ export default function ContractsList() {
</Table.Tr> </Table.Tr>
{isOpen && ( {isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}> <Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td colSpan={10} style={{ padding: "6px 20px 18px" }}> <Table.Td colSpan={11} style={{ padding: "6px 20px 18px" }}>
<ContractStepBanner contract={c} /> <ContractStepBanner contract={c} />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>

View File

@@ -693,8 +693,25 @@ export default function NewContractPage({
title="A reviewer asked for changes" title="A reviewer asked for changes"
mb="lg" mb="lg"
> >
Update any contract detail or document that needs to change, then {editContract?.latestChangeRequestNote ? (
resubmit the contract for review. <Stack gap={6}>
<Text size="sm" fw={600}>
What the reviewer asked for:
</Text>
<Text
size="sm"
style={{ whiteSpace: "pre-wrap" }}
>
{editContract.latestChangeRequestNote}
</Text>
<Text size="sm" c="dimmed" mt={2}>
Update the details or documents below, then resubmit the
contract for review.
</Text>
</Stack>
) : (
"Update any contract detail or document that needs to change, then resubmit the contract for review."
)}
</Alert> </Alert>
)} )}

View File

@@ -561,6 +561,12 @@ export interface IContract extends BaseEntity {
expiresAt?: string | null; expiresAt?: string | null;
status: ContractStatus; status: ContractStatus;
/**
* Body of the latest staff CHANGES_REQUESTED review note (detail response
* only, when status is CHANGES_REQUESTED). Lets the portal show the customer
* exactly what to fix before resubmitting.
*/
latestChangeRequestNote?: string | null;
clearanceStatus: ContractClearanceStatus; clearanceStatus: ContractClearanceStatus;
clearanceCycleNumber: number; clearanceCycleNumber: number;
/** /**