diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 3809dc086..e8ad4620e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -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 = diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 29fec7997..1896034cc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index 969de5583..0e858e702 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -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(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 1cc6503df..5a63beca6 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index fd1f530b8..b7484275d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index bc1b4ad49..2d26f51dc 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 107f2ab33..c3e03c4eb 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index e4d5810ea..67a3bd101 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository { // 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 { } } + /** + * 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 { + 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> { const rows = await this.repository .createQueryBuilder('contract') 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 c98e24b19..a675adf39 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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; } 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 b5e0b8fb1..4838d3f52 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 @@ -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; } diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index d65b2e2d8..03908b62b 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -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 diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 3618384b3..dcdfe6e39 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -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({ + {contract.status === "REJECTED" && contract.latestRejectionNote ? ( + } + title="Rejection reason" + > + + {contract.latestRejectionNote} + + + ) : null} + + {contract.status === "PENDING_APPROVAL" && contract.latestSendBackNote ? ( + } + title="Sent back in the approval chain" + > + + {contract.latestSendBackNote} + + + ) : null} + setTab(v ?? "details")} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 89870ee93..f6c3098a0 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -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}`} )} @@ -852,7 +856,9 @@ export default function ContractDetailPage() { {customsPath ? "Customs clearance shipment" - : "Customs clearance required"} + : isIntercity + ? "Intercity documents required" + : "Customs clearance required"} @@ -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."} {contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (