diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index eeea43a39..59dad2248 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index e033f8e9b..4a58e50be 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -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 { - 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; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 0e81f8dca..aaf064bff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 07d08d3c0..0b0fab41b 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 889af7cc5..e2e339e9b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index 41e77625b..d2bd8b88f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -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() { Contract + Type Cargo Route Trade @@ -352,7 +354,7 @@ export default function ContractsList() { {isLoading && ( - +
@@ -362,7 +364,7 @@ export default function ContractsList() { {!isLoading && isError && ( - +
Failed to load contracts. Please try again. @@ -374,7 +376,7 @@ export default function ContractsList() { {!isLoading && !isError && rows.length === 0 && ( - + @@ -442,10 +444,18 @@ export default function ContractsList() { {c.reference} - {isGeneral ? "General" : "One-Time"} ·{" "} {isContainer ? "Containerised" : "Bulk"} + + + {isGeneral ? "General" : "One-Time"} + + {isContainer ? ( @@ -533,7 +543,7 @@ export default function ContractsList() { {isOpen && ( - + diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 6bd6994f6..c8021ed27 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -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 ? ( + + + What the reviewer asked for: + + + {editContract.latestChangeRequestNote} + + + Update the details or documents below, then resubmit the + contract for review. + + + ) : ( + "Update any contract detail or document that needs to change, then resubmit the contract for review." + )} )} diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index bf2778575..c6b97fab1 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -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; /**