From aeb5e0046e8f3304e05a62fb3ceec2caeafc8b92 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 29 Jun 2026 08:08:10 +0000 Subject: [PATCH] implement Global Logistics booking process for customs contracts and enhance contract document handling --- .../contracts/contract-booking.service.ts | 14 +- .../modules/contracts/contracts.service.ts | 48 +++++- .../src/modules/wagons/wagons.service.ts | 7 + .../contracts/GlCreateBookingForm.tsx | 161 +++++++++++++++++- .../contracts/gl-booking-form/total.ts | 132 ++++++++++++++ .../contracts/ContractClearanceListPage.tsx | 34 +++- .../contracts/ContractClearancePanel.tsx | 21 +-- .../pages/contracts/ContractDetailPage.tsx | 32 ++-- .../src/pages/contracts/ContractsList.tsx | 15 ++ .../src/pages/contracts/NewContractPage.tsx | 27 +-- .../src/pages/contracts/NewShipmentPage.tsx | 21 +-- .../contracts/contract-booking-action.ts | 18 +- .../contracts/new-contract-form/schema.ts | 10 +- 13 files changed, 436 insertions(+), 104 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts 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 59eba6982..e7b4c373c 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 @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -191,15 +192,20 @@ export class ContractBookingService { */ private async assertGate(contract: Contract, isGlActor: boolean): Promise { if (contract.customsClearingEnabled) { - // Path B — the customer creates the booking once GL has finalized the - // pre-booking clearance (GL "create booking" was removed; clearance ends - // at finalize and hands the booking back to the customer). + // Path B — Global Logistics creates the booking ON BEHALF OF the customer + // once GL has finalized the pre-booking clearance. The customer never + // books a customs contract himself. + if (!isGlActor) { + throw new ForbiddenException( + 'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.', + ); + } if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') { throw new BadRequestException( 'Contract clearance is not ready for booking yet.', ); } - return isGlActor ? 'GL_ET' : 'CUSTOMER'; + return 'GL_ET'; } // Path A — customer (or staff) once the contract is executed. 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 cc788888c..70d2632fa 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -8,7 +8,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; +import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { FilesService } from '../files/files.service'; @@ -220,9 +220,55 @@ export class ContractsService { } } + // Attach the company profile's onboarding / business-license documents to the + // contract by reference. The separate "Documents" intake step was removed — + // the profile documents are simply carried onto every contract automatically. + await this.attachProfileDocuments(contract.id, companyProfileId); + return { contract: await this.findById(contract.id), warnings }; } + /** + * Copy a company profile's stored business-license / onboarding documents onto + * a contract by reference (no byte re-upload). Codes are slugged from each + * document name so they group under "Profile documents" on the contract detail + * page. No-op when the contract has no profile or the profile has no documents. + */ + private async attachProfileDocuments( + contractId: string, + companyProfileId: string | null, + ): Promise { + if (!companyProfileId) return; + const profile = await this.dataSource + .getRepository(CompanyProfile) + .findOne({ where: { id: companyProfileId } }); + const docs = profile?.businessLicenseFiles ?? []; + if (docs.length === 0) return; + + const slug = (name: string) => + name + .toLowerCase() + .replace(/\.[a-z0-9]+$/, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'profile_document'; + + try { + await this.filesService.attachExistingFiles( + contractId, + 'contracts', + docs.map((d, i) => ({ + code: `${slug(d.name)}_${i + 1}`, + name: d.name, + url: d.url, + size: d.size, + mimeType: d.mimeType, + })), + ); + } catch { + // Non-fatal — the contract is still valid without the carried documents. + } + } + private async persistRoutes( contractId: string, routes: CreateContractDto['routes'], diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 1350a7fd0..ac10a2ef3 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -77,6 +77,13 @@ export class WagonsService { async update(id: string, dto: UpdateWagonDto): Promise { const wagon = await this.findById(id); Object.assign(wagon, dto); + // `findById` eager-loads `currentYard`; when the DTO changes the scalar FK + // TypeORM otherwise re-derives `current_yard_id` from the STALE relation + // object (the old yard) on save and silently reverts the change. Drop the + // relation so the scalar `currentYardId` wins. + if (dto.currentYardId !== undefined) { + wagon.currentYard = null; + } await this.wagonRepo.save(wagon); // Re-read with the relation so the response reflects the new yard label // instead of the stale relation object loaded before the assign. diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 287b5e1f3..960ea6c90 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -11,19 +11,25 @@ import { Grid, Group, Loader, + Modal, NumberInput, + Paper, Select, Stack, Text, Textarea, TextInput, + ThemeIcon, } from "@mantine/core"; import { + CheckCircle2, Container as ContainerIcon, FileText, Package, Plus, + Receipt, Trash2, + X, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -36,6 +42,11 @@ import { useContractMutations, } from "@/hooks/contracts/useContracts"; import { Boxes } from "lucide-react"; +import { + computeGlShipmentTotal, + formatRateUnit, + type GlShipmentQuantities, +} from "./gl-booking-form/total"; interface UnitDraft { containerNumber: string; @@ -73,6 +84,9 @@ export default function GlCreateBookingForm() { const [notes, setNotes] = useState(""); const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); + // Price-confirm modal — GL reviews the estimate before booking on behalf of + // the customer, mirroring the portal customer flow. + const [priceOpen, setPriceOpen] = useState(false); const isContainer = contract?.freightType === "CONTAINER"; const routes = useMemo( @@ -104,6 +118,34 @@ export default function GlCreateBookingForm() { const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? ""; + // Normalized quantities for the client-side price estimate (same source the + // portal customer sees: the contract's frozen unit rates × entered qty). + const quantities: GlShipmentQuantities = useMemo( + () => ({ + isContainer, + containers: containerLines.map((l) => ({ + containerSize: l.containerSize, + quantity: l.units.length, + hazardousQuantity: Number(l.hazardousQuantity || 0), + reeferQuantity: Number(l.reeferQuantity || 0), + })), + bulkQuantity: bulkLines.reduce( + (s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0), + 0, + ), + bulkHazardousQuantity: bulkLines.reduce( + (s, l) => s + Number(l.hazardousQuantity || 0), + 0, + ), + }), + [isContainer, containerLines, bulkLines], + ); + + const priceTotal = useMemo( + () => (contract ? computeGlShipmentTotal(contract, quantities) : null), + [contract, quantities], + ); + if (isLoading) { return ( @@ -119,7 +161,7 @@ export default function GlCreateBookingForm() { ); @@ -233,12 +275,15 @@ export default function GlCreateBookingForm() { + + {/* Price-confirm — GL reviews the estimate, then books on behalf of the + customer. The server recomputes the authoritative total on submit. */} + { + if (!mutations.createBooking.isPending) setPriceOpen(false); + }} + centered + radius="lg" + size="lg" + title={ + + + + + + + Confirm shipment price + + + Booking on behalf of the customer for {contract.reference}. + + + + } + > + {priceTotal ? ( + + + + {priceTotal.lines.map((line, i) => ( + + + + {line.label} + + + {line.quantity.toLocaleString()} ×{" "} + {line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "} + {formatRateUnit(line.unit)} + + + + {line.amount.toLocaleString()} {priceTotal.currency} + + + ))} + {priceTotal.lines.length === 0 && ( + + No priced lines — check the cargo details. + + )} + + + + + Total + + + {priceTotal.total.toLocaleString()}{" "} + + {priceTotal.currency} + + + + + + + + + + + ) : null} + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts new file mode 100644 index 000000000..6747b4112 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -0,0 +1,132 @@ +import type { Freight } from "@edr/types"; + +export interface GlShipmentTotalLine { + label: string; + unitPrice: number; + unit: Freight.ContractRateUnit | string; + quantity: number; + amount: number; +} + +export interface GlShipmentTotal { + currency: string; + lines: GlShipmentTotalLine[]; + total: number; +} + +/** A normalized view of the form quantities, freight-shape agnostic. */ +export interface GlShipmentQuantities { + isContainer: boolean; + /** Container lines: size + total qty + hazardous/reefer qty. */ + containers: Array<{ + containerSize: string; + quantity: number; + hazardousQuantity: number; + reeferQuantity: number; + }>; + /** Bulk: tons (or item count) + hazardous qty. */ + bulkQuantity: number; + bulkHazardousQuantity: number; +} + +/** + * Compute the booking total client-side from the contract's frozen unit rates × + * the quantities GL enters. Mirrors the portal customer estimate + * (new-shipment-form/total.ts) — the server recomputes the authoritative total + * on submit. Shown in the price-confirm modal before GL books on behalf of the + * customer. + */ +export function computeGlShipmentTotal( + contract: Freight.IContract, + q: GlShipmentQuantities, +): GlShipmentTotal { + const breakdown = contract.pricingBreakdown; + const currency = breakdown?.currency ?? contract.paymentCurrency ?? "ETB"; + const items = breakdown?.lineItems ?? []; + const lines: GlShipmentTotalLine[] = []; + + const rateFor = ( + predicate: (i: Freight.ContractUnitRateLineItem) => boolean, + ) => items.find(predicate); + + if (q.isContainer) { + let hazardTotalQty = 0; + let reeferTotalQty = 0; + + for (const line of q.containers) { + const qty = line.quantity; + if (qty <= 0) continue; + const rate = + rateFor( + (i) => + i.containerSize === line.containerSize && + i.unit === "per_container" && + !i.conditionalOn, + ) ?? rateFor((i) => i.containerSize === line.containerSize); + if (rate) { + lines.push({ + label: rate.label, + unitPrice: rate.unitPrice, + unit: rate.unit, + quantity: qty, + amount: rate.unitPrice * qty, + }); + } + hazardTotalQty += line.hazardousQuantity; + reeferTotalQty += line.reeferQuantity; + } + + if (contract.isHazardous && hazardTotalQty > 0) { + const hz = rateFor((i) => i.conditionalOn === "is_hazardous"); + if (hz) { + lines.push({ + label: hz.label, + unitPrice: hz.unitPrice, + unit: hz.unit, + quantity: hazardTotalQty, + amount: hz.unitPrice * hazardTotalQty, + }); + } + } + if (contract.isReefer && reeferTotalQty > 0) { + const rf = rateFor((i) => i.conditionalOn === "is_reefer"); + if (rf) { + lines.push({ + label: rf.label, + unitPrice: rf.unitPrice, + unit: rf.unit, + quantity: reeferTotalQty, + amount: rf.unitPrice * reeferTotalQty, + }); + } + } + } else { + const qty = q.bulkQuantity; + const rate = + rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0]; + if (rate && qty > 0) { + lines.push({ + label: rate.label, + unitPrice: rate.unitPrice, + unit: rate.unit, + quantity: qty, + amount: rate.unitPrice * qty, + }); + } + } + + const total = lines.reduce((s, l) => s + l.amount, 0); + return { currency, lines, total }; +} + +/** Human-readable label for a contract unit-rate's charge unit. */ +export function formatRateUnit(unit: Freight.ContractRateUnit | string): string { + const map: Record = { + per_container: "container", + per_ton: "ton", + per_item: "item", + per_km: "km", + flat: "flat", + }; + return map[unit] ?? unit.replace(/_/g, " ").replace(/^per /, ""); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index e9d83e20c..68f04d5bb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -4,6 +4,7 @@ import { ActionIcon, Badge, Box, + Button, Card, Group, SegmentedControl, @@ -20,6 +21,7 @@ import { Inbox, LayoutGrid, PackageCheck, + PackagePlus, RefreshCw, Search, ShieldCheck, @@ -293,15 +295,33 @@ export default function ContractClearanceListPage() { }, { id: "go", - size: 56, - cell: () => ( - - - - ), + size: 150, + cell: ({ row }) => + row.original.ready ? ( + + + + ) : ( + + + + ), }, ], - [], + [navigate], ); return ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx index b78b155ba..6aa01e2e0 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Alert, @@ -21,7 +20,6 @@ import { Download, Eye, FileText, - PackagePlus, Plus, Upload, } from "lucide-react"; @@ -87,7 +85,6 @@ export function ContractClearancePanel({ bare, }: ContractClearancePanelProps) { const queryClient = useQueryClient(); - const navigate = useNavigate(); const [pending, setPending] = useState>({}); const [adHoc, setAdHoc] = useState([]); const { view, viewer } = useFileViewer(); @@ -195,8 +192,9 @@ export function ContractClearancePanel({ )} {isReady ? ( } mb="md"> - Your clearance documents are approved. You can now create a shipment - booking under this contract. + {customsPath + ? "Your clearance documents are approved. Global Logistics will create your booking on your behalf — you will be notified when payment is due." + : "Your clearance documents are approved. You can now create a shipment booking under this contract."} ) : isUnderReview ? ( } mb="md"> @@ -443,19 +441,6 @@ export function ContractClearancePanel({ )} - {/* Clearance finalized → the customer creates the booking himself. */} - {isReady && status === "CLEARANCE_READY_FOR_BOOKING" && ( - - - - )} {viewer} ); 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 4185d3a18..4e40313c9 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -205,19 +205,20 @@ export default function ContractDetailPage() { const canSign = contract.status === "CONTRACT_READY"; const customsPath = contract.customsClearingEnabled; - // The customer creates the booking himself on BOTH paths: - // - Path A (no customs): once the contract is executed after self-clearance. - // - Path B (customs): once GL finalizes the pre-booking clearance - // (CLEARANCE_READY_FOR_BOOKING). GL "create booking" was removed. + // 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 + // button on a customs contract. const clearanceFinalized = contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" || contract.status === "CLEARANCE_READY_FOR_BOOKING"; - const canBookShipment = customsPath - ? clearanceFinalized - : PATH_A_BOOKABLE.includes(contract.status); - // The customer uploads clearance documents while in a clearance status — but - // once clearance is finalized the upload step is done; the action becomes - // "create booking" instead. + const canBookShipment = + !customsPath && PATH_A_BOOKABLE.includes(contract.status); + // Customs + clearance finalized: GL is preparing the booking — surface a + // status notice instead of any action. + const glPreparingBooking = customsPath && clearanceFinalized; + // The customer uploads clearance documents while in a clearance status, until + // clearance is finalized. const canUploadClearance = CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized; @@ -292,6 +293,17 @@ export default function ContractDetailPage() { New shipment booking )} + {glPreparingBooking && ( + } + > + Global Logistics is creating your booking + + )} {canUploadClearance && (