Implement intercity document handling and rejection notes for contracts

This commit is contained in:
Marshal
2026-07-21 10:15:18 +00:00
parent 603537a20b
commit ceb32e0a80
16 changed files with 287 additions and 23 deletions

View File

@@ -168,6 +168,17 @@ export class BookingLifecycleNotifierService {
});
}
/** Intercity documents approved → booking waits in the ride-along pool. */
intercityDocumentsApproved(b: Booking): void {
const msg =
`Documents for intercity booking ${b.reference} are approved. ` +
`Operations will assign your shipment to a passing train; payment opens once it is accepted.`;
void this.notifyContact(b, msg, 'DOCUMENTS APPROVED');
this.inApp(b, 'Documents approved', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
/** Operations returned the operation request for changes. */
operationChangesRequested(b: Booking, note: string): void {
const msg =

View File

@@ -840,6 +840,22 @@ export class BookingTransitionService {
}
}
// Intercity: there is no shipment-day request step — an approved booking
// goes straight to FULLY_EXECUTED, which is what the intercity ride-along
// pool keys on. Staff then accept it onto a passing train (that accept
// opens the pay window).
if (booking.tradeDirection === "DOMESTIC") {
const now = new Date();
await this.bookingsRepository.update(bookingId, {
status: "FULLY_EXECUTED",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.intercityDocumentsApproved(fresh);
return fresh;
}
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);

View File

@@ -1,7 +1,10 @@
import {
clearanceSettingCode,
clearanceOutputSettingCode,
clearanceCodesForBooking,
INTERCITY_DOCUMENTS_SETTING_CODE,
} from './clearance.util';
import type { Booking } from './entities/booking.entity';
describe('clearance.util — clearanceSettingCode', () => {
it('resolves import container with/without customs', () => {
@@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => {
);
});
it('returns null for DOMESTIC (no clearance gate)', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
});
});
describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
const base = {
tradeDirection: 'DOMESTIC',
freightType: 'CONTAINER',
serviceType: null,
customsClearingEnabled: false,
};
it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => {
const general = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'GENERAL',
} as unknown as Booking);
expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
expect(general.outputCode).toBeNull();
const direct = clearanceCodesForBooking({
...base,
contractId: null,
contractKind: null,
} as unknown as Booking);
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
});
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
const drawdown = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'ONE_TIME',
} as unknown as Booking);
expect(drawdown.inputCode).toBeNull();
expect(drawdown.outputCode).toBeNull();
});
});

View File

@@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity';
type Op = 'import' | 'export';
type Freight = 'container' | 'bulk';
/**
* The single (admin-configured) document set intercity shipments upload.
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
* reviews either way.
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
function operationFor(tradeDirection: string): Op | null {
if (tradeDirection === 'IMPORT') return 'import';
if (tradeDirection === 'EXPORT') return 'export';
return null; // DOMESTIC / intercity — no clearance gate
return null; // DOMESTIC / intercity — no customs operation
}
function freightFor(freightType: string): Freight {
@@ -26,6 +34,9 @@ export function clearanceSettingCode(
freightType: string,
includesCustoms: boolean,
): string | null {
// Intercity: no customs, but the admin-configured intercity document set is
// still collected and ops-reviewed before the shipment may board a train.
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
@@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): {
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
// direct (contract-less) bookings carry the per-booking set.
if (
booking.tradeDirection === 'DOMESTIC' &&
booking.contractId &&
booking.contractKind === 'ONE_TIME'
) {
return { inputCode: null, outputCode: null, includesCustoms: false };
}
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -196,11 +196,12 @@ export class ContractBookingService {
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). DOMESTIC has no border, so no gate.
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
// per-booking gate with the intercity document set — ops finalize then puts
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
// intercity has no shipment-day request step.
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at

View File

@@ -1,4 +1,5 @@
import { Contract } from './entities/contract.entity';
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
/**
* Resolves which seeded clearance FileUploadSetting applies to a contract during
@@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight {
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
* reviewed by Operations rather than GL.
*
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
* collects the admin-configured intercity document set after both signatures
* (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
* gate and collect the same set per booking instead.
*/
export function contractClearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);

View File

@@ -1092,8 +1092,8 @@ export class ContractTransitionService {
};
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
// resolves to null on both paths and skips straight to executed.
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
// intercity document set (DOMESTIC, ops-reviewed like Path A).
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,

View File

@@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
// direct download. Loaded separately to keep pagination counts correct.
await this.attachContractFiles(items);
await this.attachClearancePhases(items);
await this.attachRejectionNotes(items);
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
return {
@@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
}
}
/**
* Attach the latest REJECTION review-note body to each REJECTED contract so
* list consumers (portal rows, backoffice queues) can show why without a
* per-contract detail fetch. One query per page, like `attachContractFiles`.
*/
private async attachRejectionNotes(contracts: Contract[]): Promise<void> {
const rejected = contracts.filter((c) => c.status === 'REJECTED');
if (rejected.length === 0) return;
const ids = rejected.map((c) => c.id);
const rows: Array<{ contract_id: string; body: string }> =
await this.dataSource.query(
`SELECT DISTINCT ON (contract_id) contract_id, body
FROM freight.contract_review_notes
WHERE contract_id = ANY($1)
AND note_type = 'REJECTION'
AND deleted_at IS NULL
ORDER BY contract_id, created_at DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r.body]));
for (const contract of rejected) {
contract.latestRejectionNote = byContract.get(contract.id) ?? null;
}
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('contract')

View File

@@ -642,6 +642,47 @@ export class ContractsService {
}
}
// Surface the rejection reason. The approval-step note is wiped on
// send-back resets, so the review-note trail is the only durable source.
if (contract.status === 'REJECTED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'REJECTION',
);
contract.latestRejectionNote = note?.body ?? null;
} catch {
contract.latestRejectionNote = null;
}
}
// Surface the send-back reason to the returned-to approver, but only while
// it is still actionable: once any step acts after the send-back the note
// is stale and stays out of the response (the trail keeps it in the DB).
if (contract.status === 'PENDING_APPROVAL') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'STAFF_NOTE',
);
// Stale when any step acted after it (send-back resolved) or when the
// chain itself is newer than the note (fresh cycle after a resubmit).
const staleAfter = Math.max(
0,
...(contract.approvalSteps ?? []).flatMap((s) => [
s.actedAt ? new Date(s.actedAt).getTime() : 0,
s.createdAt ? new Date(s.createdAt).getTime() : 0,
]),
);
contract.latestSendBackNote =
note && new Date(note.createdAt).getTime() > staleAfter
? note.body
: null;
} catch {
contract.latestSendBackNote = null;
}
}
return contract;
}

View File

@@ -326,4 +326,19 @@ export class Contract extends BaseEntity {
* asked them to fix. Lives in contract_review_notes, not a column here.
*/
latestChangeRequestNote?: string | null;
/**
* Body of the most recent REJECTION review note, attached by
* ContractsService.findById when status is REJECTED so both backoffice and
* portal can show why. Lives in contract_review_notes, not a column here.
*/
latestRejectionNote?: string | null;
/**
* Body of the most recent send-back STAFF_NOTE, attached by
* ContractsService.findById while the contract is PENDING_APPROVAL and no
* approval step has acted since the send-back. Lives in
* contract_review_notes, not a column here.
*/
latestSendBackNote?: string | null;
}

View File

@@ -568,6 +568,20 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
},
];
// ── Intercity documents ─────────────────────────────────────────────────────
// One shared set for DOMESTIC (intercity) shipments, reviewed by Operations.
// ONE_TIME contracts collect it at contract level after both signatures;
// GENERAL contracts collect it per booking right after the booking is created.
// Fields start empty and are configured in the backoffice file-settings editor.
const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "intercity_documents",
label: "Intercity documents",
entity: "booking",
fields: [],
},
];
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
@@ -619,6 +633,11 @@ export class FileUploadSettingsSeeder {
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.",
})),
];
// Insert setting rows only — no FileUploadField rows. Fields start empty

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
import { Check, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -51,6 +51,11 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
// The card also renders read-only trails (e.g. a REJECTED contract) — only
// offer approve/reject while the backend accepts step actions.
const actionable =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
// Approvers review a live preview of the document; there is no PDF to
// generate first — the final approval is what produces it.
@@ -160,7 +165,7 @@ export function ContractApprovalStepsCard({
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
isNext={actionable && nextPending?.id === step.id}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending

View File

@@ -1,6 +1,7 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Box as BoxIcon,
@@ -22,6 +23,7 @@ import {
Users,
} from "lucide-react";
import {
Alert,
Badge,
Box,
Button,
@@ -264,7 +266,8 @@ export default function ContractRequestDetailPage() {
const showApprovalCard =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
contract.status === "APPROVED_PENDING_SIGNATURE" ||
contract.status === "REJECTED";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
const phasedCustoms =
@@ -428,6 +431,32 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={18} />}
title="Rejection reason"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestRejectionNote}
</Text>
</Alert>
) : null}
{contract.status === "PENDING_APPROVAL" && contract.latestSendBackNote ? (
<Alert
color="orange"
radius="md"
icon={<AlertTriangle size={18} />}
title="Sent back in the approval chain"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestSendBackNote}
</Text>
</Alert>
) : null}
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}

View File

@@ -409,6 +409,10 @@ export default function ContractDetailPage() {
const canSign = contract.status === "CONTRACT_READY";
const customsPath = contract.customsClearingEnabled;
// Intercity (DOMESTIC) has no customs — the document gate collects the
// admin-configured intercity set, reviewed by Operations.
const isIntercity = contract.tradeDirection === "DOMESTIC";
const docNoun = isIntercity ? "intercity documents" : "clearance documents";
// Only the NON-customs (Path A) customer books himself — once the contract is
// executed after self-clearance. Customs (Path B) bookings are created by
// Global Logistics on the customer's behalf, so the customer gets no booking
@@ -586,8 +590,8 @@ export default function ContractDetailPage() {
onClick={clearanceModal.open}
>
{contract.status === "CLEARANCE_UNDER_REVIEW"
? "Manage clearance documents"
: "Upload clearance documents"}
? `Manage ${docNoun}`
: `Upload ${docNoun}`}
</Button>
)}
</Group>
@@ -852,7 +856,9 @@ export default function ContractDetailPage() {
<Text fw={700} fz={15} c={INK}>
{customsPath
? "Customs clearance shipment"
: "Customs clearance required"}
: isIntercity
? "Intercity documents required"
: "Customs clearance required"}
</Text>
</Group>
<Text fz={13} c="dimmed">
@@ -862,11 +868,17 @@ export default function ContractDetailPage() {
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your documents are cleared. You can now create a shipment booking under this contract."
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
: isIntercity
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload the required intercity documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your intercity documents. Re-upload any queried documents to proceed."
: "Your intercity documents are approved. You can now create a shipment booking under this contract."
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Text>
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
<Button
@@ -1581,7 +1593,7 @@ export default function ContractDetailPage() {
onClose={clearanceModal.close}
title={
<Text fw={700} fz={16}>
Clearance documents
{isIntercity ? "Intercity documents" : "Clearance documents"}
</Text>
}
size="xl"

View File

@@ -254,6 +254,14 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) {
</Text>
</Group>
)}
{contract.status === "REJECTED" && contract.latestRejectionNote && (
<Text
fz={12.5}
style={{ color: "#B42318", whiteSpace: "pre-wrap", width: "100%" }}
>
Reason: {contract.latestRejectionNote}
</Text>
)}
{expirySoon && (
<Group gap={6} wrap="nowrap" align="center">
<AlertTriangle size={14} color="#9A6700" />