mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
implement self-healing for freight payment milestones and enhance contract change request visibility
This commit is contained in:
@@ -176,7 +176,28 @@ export class BookingClearanceService {
|
||||
}
|
||||
|
||||
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 nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
|
||||
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CustomsRiskLevel,
|
||||
MilestoneMetadata,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import {
|
||||
HANDOFF_MILESTONES,
|
||||
@@ -85,10 +86,36 @@ export class ClearanceMilestoneService {
|
||||
}
|
||||
|
||||
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.repo.find({
|
||||
const rows = await this.repo.find({
|
||||
where: { bookingId },
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -260,4 +260,11 @@ export class Contract extends BaseEntity {
|
||||
* ContractsRepository.attachClearancePhases for list responses. Not a column.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -382,6 +382,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`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(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Fragment, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -339,6 +340,7 @@ export default function ContractsList() {
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 44 }} aria-label="Expand" />
|
||||
<Table.Th>Contract</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Trade</Table.Th>
|
||||
@@ -352,7 +354,7 @@ export default function ContractsList() {
|
||||
<Table.Tbody>
|
||||
{isLoading && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10}>
|
||||
<Table.Td colSpan={11}>
|
||||
<Center py={48}>
|
||||
<Loader color="edr-green" size="sm" />
|
||||
</Center>
|
||||
@@ -362,7 +364,7 @@ export default function ContractsList() {
|
||||
|
||||
{!isLoading && isError && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10}>
|
||||
<Table.Td colSpan={11}>
|
||||
<Center py={48}>
|
||||
<Text fz={13} c="red">
|
||||
Failed to load contracts. Please try again.
|
||||
@@ -374,7 +376,7 @@ export default function ContractsList() {
|
||||
|
||||
{!isLoading && !isError && rows.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10}>
|
||||
<Table.Td colSpan={11}>
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
|
||||
<Text fz={13} c="dimmed">
|
||||
@@ -442,10 +444,18 @@ export default function ContractsList() {
|
||||
{c.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
{isGeneral ? "General" : "One-Time"} ·{" "}
|
||||
{isContainer ? "Containerised" : "Bulk"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={isGeneral ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{isGeneral ? "General" : "One-Time"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={7} wrap="nowrap" align="center">
|
||||
{isContainer ? (
|
||||
@@ -533,7 +543,7 @@ export default function ContractsList() {
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<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} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -693,8 +693,25 @@ export default function NewContractPage({
|
||||
title="A reviewer asked for changes"
|
||||
mb="lg"
|
||||
>
|
||||
Update any contract detail or document that needs to change, then
|
||||
resubmit the contract for review.
|
||||
{editContract?.latestChangeRequestNote ? (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -561,6 +561,12 @@ export interface IContract extends BaseEntity {
|
||||
expiresAt?: string | null;
|
||||
|
||||
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;
|
||||
clearanceCycleNumber: number;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user