mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +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 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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user