Merge pull request #499 from Tria-plc/freight_feature/usermanagement

implement self-healing for freight payment milestones and enhance con…
This commit is contained in:
marshal
2026-07-07 10:51:23 +03:00
committed by GitHub
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 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(

View File

@@ -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;
}
/**

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;
}

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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;
/**