From c7617b1b6efd6c38da05568ec17c225c1b4468f7 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 5 Jun 2026 09:46:00 +0300 Subject: [PATCH 1/4] chore: deps --- apps/edr-freight-web/portal/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 9afc896f7..3cd617e0a 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -25,6 +25,7 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-hook-form": "^7.76.0", + "react-hot-toast": "^2.6.0", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", "tailwind-merge": "^3.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 834c00e55..7270938b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,6 +307,9 @@ importers: react-hook-form: specifier: ^7.76.0 version: 7.76.0(react@19.2.6) + react-hot-toast: + specifier: ^2.6.0 + version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router-dom: specifier: ^6.27.0 version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) From c5859dba6cdd0361e462fcde76c34a0ccc55a828 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 5 Jun 2026 09:47:46 +0300 Subject: [PATCH 2/4] feat(bookings): Implement document upload API for DRAFT bookings --- .../src/modules/bookings/bookings.controller.ts | 12 ++++++++++++ .../src/modules/bookings/bookings.service.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 03c5cfde4..367aab41f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -147,6 +147,18 @@ export class BookingsController { return this.bookingsService.remove(id); } + @Post(':id/documents') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + async uploadDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + const booking = await this.bookingsService.uploadDocuments(id, files ?? []); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/generate-price') @ApiOperation({ summary: 'Generate price preview (DRAFT only)' }) @ApiOkResponse({ type: GeneratePriceResponseDto }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 89085f144..b18a2600b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -452,6 +452,21 @@ export class BookingsService { return this.findById(booking.id); } + /** Upload documents for a DRAFT booking. */ + async uploadDocuments( + id: string, + files: Express.Multer.File[], + ): Promise { + const booking = await this.findById(id); + if (booking.status !== 'DRAFT') { + throw new BadRequestException( + 'Documents can only be uploaded for DRAFT bookings', + ); + } + await this.filesService.uploadMany(id, 'bookings', files); + return this.findById(id); + } + async remove(id: string): Promise { const booking = await this.findById(id); if (booking.status !== 'DRAFT') { From 0184db904053b67d6782d0ef012392e4e28ea26e Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 5 Jun 2026 09:50:09 +0300 Subject: [PATCH 3/4] feat(bookings): Introduce DRAFT booking workflow with comprehensive features for pricing, document management, submission, and cancellation. --- .../src/pages/bookings/BookingDetailPage.tsx | 531 +++++++++++++++++- .../src/pages/bookings/NewBookingPage.tsx | 66 +-- .../pages/bookings/new-booking-form/schema.ts | 106 +--- .../new-booking-form/step5-cargo-details.tsx | 117 ++-- .../portal/src/services/api.ts | 31 +- .../portal/src/services/bookings.service.ts | 51 ++ .../services/files/file_upload_settings.ts | 3 - 7 files changed, 649 insertions(+), 256 deletions(-) delete mode 100644 apps/edr-freight-web/portal/src/services/files/file_upload_settings.ts diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index f8ea6c496..73977fc7c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -1,6 +1,8 @@ +import { useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + AlertCircle, Calendar, MapPin, Package, @@ -24,21 +26,29 @@ import { FileSignature, PackageCheck, LoaderCircle, + DollarSign, + Upload, + XCircle, + Building2, + FileUp, } from "lucide-react"; import Breadcrumbs from "@/components/Breadcrumbs"; import { api } from "@/services/api"; import type { Freight } from "@edr/types"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, +import type { GeneratePriceResponse } from "@/services/bookings.service"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, Badge, + Button, Separator, } from "@edr/ui-common"; import { cn } from "@/lib/utils"; +import useAuth from "@/hooks/useAuth"; const PROGRESS_STAGES = [ { label: "Request", icon: FileText, statuses: ["DRAFT"] }, @@ -55,9 +65,17 @@ const STATUS_MAP: Record(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { data: booking, isLoading, isError, error } = useQuery( api.bookings.get.queryOptions({ @@ -66,6 +84,10 @@ export default function BookingDetailPage() { }), ); + const refetchBooking = () => { + queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) }); + }; + if (isLoading) { return (
@@ -110,17 +132,474 @@ export default function BookingDetailPage() { ); } + if (booking.status === "DRAFT") { + return ; + } + + return ; +} + +function DraftBookingView({ + booking, + onBookingUpdated, +}: { + booking: Freight.IBooking; + onBookingUpdated: () => void; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { customer } = useAuth(); + const fileInputRefs = useRef>({}); + + const [pricingData, setPricingData] = useState(null); + const [selectedFiles, setSelectedFiles] = useState>({}); + const [cancelReason, setCancelReason] = useState(""); + + const anyFileSelected = Object.values(selectedFiles).some(Boolean); + const allDocsProvided = !anyFileSelected; + + const priceMutation = useMutation({ + mutationFn: () => api.bookings.generatePrice.call({ id: booking.id }), + onSuccess: (data) => { + setPricingData(data); + queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: booking.id }) }); + }, + }); + + const uploadMutation = useMutation({ + mutationFn: (files: Record) => + api.bookings.uploadDocuments.call({ id: booking.id, files }), + onSuccess: () => { + setSelectedFiles({}); + onBookingUpdated(); + }, + }); + + const submitMutation = useMutation({ + mutationFn: () => api.bookings.submit.call({ id: booking.id }), + onSuccess: () => { + onBookingUpdated(); + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + }, + }); + + const cancelMutation = useMutation({ + mutationFn: (reason: string) => + api.bookings.cancel.call({ id: booking.id, reason }), + onSuccess: () => { + onBookingUpdated(); + }, + }); + + function handleFileSelect(key: string, file: File | null) { + setSelectedFiles((prev) => ({ ...prev, [key]: file })); + } + + function handleUploadAll() { + const filesToUpload: Record = {}; + for (const doc of REQUIRED_DOC_FIELDS) { + if (selectedFiles[doc.key]) { + filesToUpload[doc.key] = selectedFiles[doc.key]!; + } + } + if (Object.keys(filesToUpload).length === 0) return; + uploadMutation.mutate(filesToUpload); + } + + function handleCancel() { + const reason = cancelReason.trim() || "Cancelled by customer"; + cancelMutation.mutate(reason); + } + + const canConfirm = !!pricingData && !uploadMutation.isPending && !submitMutation.isPending; + + const companyName = (customer as any)?.company?.name ?? "—"; + const companyTin = (customer as any)?.company?.tin ?? "—"; + const contactName = (customer as any)?.profile + ? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || "—" + : "—"; + const contactEmail = (customer as any)?.profile?.email ?? "—"; + + return ( +
+
+ + + + +
+
+ +
+
+
+

+ {booking.reference} +

+ +
+

+ Complete the steps below to submit your booking request. +

+
+
+
+
+ + {priceMutation.isError && ( +
+ +
+

Pricing failed

+

+ {priceMutation.error instanceof Error + ? priceMutation.error.message + : "An unexpected error occurred."} +

+
+
+ )} + + {uploadMutation.isError && ( +
+ +
+

Document upload failed

+

+ {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "An unexpected error occurred."} +

+
+
+ )} + + {submitMutation.isError && ( +
+ +
+

Submission failed

+

+ {submitMutation.error instanceof Error + ? submitMutation.error.message + : "An unexpected error occurred."} +

+
+
+ )} + + {cancelMutation.isError && ( +
+ +
+

Cancel failed

+

+ {cancelMutation.error instanceof Error + ? cancelMutation.error.message + : "An unexpected error occurred."} +

+
+
+ )} + + + + + + Pricing Estimation + + + Generate a price estimate based on your booking details. + + + + {pricingData ? ( +
+
+ + + + + + + + + {pricingData.lineItems.map((item, i) => ( + + + + + ))} + + + + + +
DescriptionAmount
{item.description} + {item.amount.toLocaleString()} {item.currency} +
Total Estimated Cost + {pricingData.totalAmount.toLocaleString()} {pricingData.currency} +
+
+ + {pricingData.warnings.length > 0 && ( +
+ {pricingData.warnings.map((w, i) => ( +

+ + {w} +

+ ))} +
+ )} + +
+ +
+
+ ) : ( +
+
+ +
+
+

No pricing yet

+

+ Generate a price estimate to review before submitting. +

+
+ +
+ )} +
+
+ + + + + + Required Documents + + + Provide the necessary documents for this booking. Some information is + pre-filled from your company profile. + + + +
+

+ + Company Information (from profile) +

+
+ + + + +
+

+ To update your company information, go to{" "} + + . +

+
+ + + +
+

+ + Upload Booking Documents +

+
+ {REQUIRED_DOC_FIELDS.map((doc) => ( +
+ +
+ { + fileInputRefs.current[doc.key] = el; + }} + type="file" + accept=".pdf,.jpg,.jpeg,.png" + className="hidden" + onChange={(e) => { + handleFileSelect(doc.key, e.target.files?.[0] ?? null); + }} + /> + + {selectedFiles[doc.key] && ( + + )} +
+
+ ))} +
+ +
+ + {uploadMutation.isSuccess && ( +

+ + Documents uploaded successfully +

+ )} +
+
+
+
+ + + + + + Cancel Booking + + + If you no longer need this booking, you can cancel it. + + + + setCancelReason(e.target.value)} + /> + + + + +
+
+ {!canConfirm && ( +

+ {!pricingData + ? "Request pricing estimation before confirming." + : "Upload documents before confirming."} +

+ )} + + +
+
+
+
+ ); +} + +function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { + const navigate = useNavigate(); + const normalizedStatus = booking.status as keyof typeof STATUS_MAP; const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT; const currentStageIndex = statusConfig.stage; - const containerCount = booking.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0; - const containerType = booking.containers?.[0]?.type ?? null; - return (
- +
-
= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }} />
@@ -193,13 +672,13 @@ export default function BookingDetailPage() { {PROGRESS_STAGES.map((stage, idx) => { const isCompleted = idx < currentStageIndex; const isActive = idx === currentStageIndex; - + return (
{isCompleted ? : } @@ -435,14 +914,14 @@ function RouteEndpoint({ ); } -function InfoItem({ - icon, - label, - value -}: { - icon?: React.ReactNode; - label: string; - value?: string | number | null +function InfoItem({ + icon, + label, + value +}: { + icon?: React.ReactNode; + label: string; + value?: string | number | null }) { return (
@@ -465,8 +944,8 @@ function StatusBadge({ status }: { status: string }) { }; return ( - {status.replace(/_/g, ' ')} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index d69258e0d..9b5a4cf73 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -6,14 +6,12 @@ import { useNavigate } from "react-router-dom"; import { AlertCircle, Check, - CheckCircle2, ChevronLeft, ChevronRight, LoaderCircle, } from "lucide-react"; import { Button } from "@edr/ui-common"; import { api } from "@/services/api"; -import { Freight } from "@edr/types"; import type { CreateBookingPayload } from "@/services/bookings.service"; import { BookingFormInputValues, @@ -32,13 +30,11 @@ import { Step5CargoDetails, Step8Review, } from "./new-booking-form/steps"; -import useAuth from "@/hooks/useAuth"; export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [step, setStep] = useState(1); - const { customer } = useAuth(); const { data: referenceData, isLoading: refDataLoading } = useQuery( api.bookings.referenceData.queryOptions(), ); @@ -48,7 +44,7 @@ export default function NewBookingPage() { api.bookings.create.call(payload), onSuccess: (booking) => { queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); - setTimeout(() => navigate(`/bookings/${booking.id}`), 2500); + navigate(`/bookings/${booking.id}`); }, }); @@ -132,23 +128,27 @@ export default function NewBookingPage() { return ""; }; + const selectedChild = + data.cargoType !== "container" && data.bulkCommoditytype + ? cargoTree + .find((g) => g.code.toLowerCase() === data.freightType) + ?.children?.find((c) => c.name === data.bulkCommoditytype) + : undefined; + const cargoTypeId = data.cargoType === "container" ? findContainerCargoTypeId() - : (findCargoTypeId( - data.freightType === "bulk" - ? data.bulkCommodity - : data.breakBulkType, - ) ?? ""); + : (findCargoTypeId(data.bulkCommoditytype) ?? + cargoTree.find((g) => g.code.toLowerCase() === data.freightType) + ?.id ?? + ""); const cargoFreeText = data.cargoType === "container" ? undefined - : data.freightType === "bulk" && data.bulkCommodity === "Others" - ? data.bulkCommodityOther - : data.freightType === "break_bulk" && data.breakBulkType === "Others" - ? data.breakBulkTypeOther - : undefined; + : selectedChild?.show_free_text_box + ? data.bulkCommoditytype + : undefined; // ── Build API payload ─────────────────────────────────────────────── const apiPayload: CreateBookingPayload = { @@ -168,15 +168,16 @@ export default function NewBookingPage() { : direction === "domestic" ? "DOMESTIC" : "IMPORT", - freightType: - data.cargoType === "container" - ? Freight.FreightType.Container - : Freight.FreightType.Bulk, cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId, cargoTotalWeightVgm: totalWeight, isHazardous: data.isHazardous, paymentCurrency: "USD", allowConsolidation: data.consolidationEnabled, + // @ts-ignore + freightType: + data.cargoType === "container" + ? ("CONTAINER" as const) + : ("BULK" as const), containers: data.cargoType === "container" ? data.containers.map((c) => ({ @@ -185,7 +186,6 @@ export default function NewBookingPage() { vgmPerUnitTons: Number(c.vgm || 0), })) : [], - ...(customer?.company?.id ? { companyId: customer.company.id } : {}), ...(data.previousContractRef ? { previousContractId: data.previousContractRef } : {}), @@ -207,26 +207,6 @@ export default function NewBookingPage() { createMutation.mutate(apiPayload); }); - if (createMutation.isSuccess) { - return ( -
-
-
- -
-

Contract Submitted

-

- Your request is queued for review by EDR Line Staff. You will be - notified once approved. -

-

- {createMutation.data?.reference} -

-
-
- ); - } - return (
-

Submission failed

+

Failed to save draft

{createMutation.error instanceof Error ? createMutation.error.message @@ -306,9 +286,7 @@ export default function NewBookingPage() { ) : ( )} - {createMutation.isPending - ? "Submitting..." - : "Submit Contract Request"} + {createMutation.isPending ? "Saving Draft..." : "Save as Draft"} )}

diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index d994f93f7..be443d081 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -1,19 +1,6 @@ import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; -export const STATIONS = [ - "Addis Ababa", - "Adama", - "Mojo", - "Awash", - "Mieso", - "Dire Dawa", - "Aysha", - "Ali Sabieh", - "Holhol", - "Djibouti City", -] as const; - export const ETHIOPIA_STATIONS = new Set([ "Addis Ababa", "Adama", @@ -23,19 +10,6 @@ export const ETHIOPIA_STATIONS = new Set([ "Dire Dawa", ]); -export const BULK_COMMODITIES = [ - "Coffee", - "Beans", - "Fertilizer", - "Sugar", - "Oil", - "Livestock", - "Steel", - "Others", -] as const; - -export const BREAK_BULK_TYPES = ["Machinery", "Ro-Ro", "Others"] as const; - export const MOCK_VALID_CONTRACTS = [ "EDR-2024-10001", "EDR-2024-10002", @@ -43,31 +17,6 @@ export const MOCK_VALID_CONTRACTS = [ "EDR-2022-55442", ]; -export const CONTAINER_TYPES = [ - "Dry Container", - "High Cubic", - "Reefer Container", - "Open Top", - "Flat Rack", - "Tank Container", - "Open Side", -] as const; - -export const SHIPPING_LINES = [ - "MSC", - "CMA CGM", - "Evergreen", - "COSCO", - "Hapag-Lloyd", - "ONE", - "Yang Ming", - "ZIM", - "Messina Line", - "Safmarine", - "Wan Hai", - "Ethiopian Shipping Lines (ESLSE)", -] as const; - export const STEPS = [ { id: 1, label: "Contract Type", short: "Contract" }, { id: 2, label: "Service Type & Mile", short: "Service" }, @@ -108,11 +57,8 @@ export const bookingFormSchema = z shippingLine: z.string(), cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoWeight: z.string(), - freightType: z.enum(["bulk", "break_bulk"]).optional(), - bulkCommodity: z.string(), - bulkCommodityOther: z.string(), - breakBulkType: z.string(), - breakBulkTypeOther: z.string(), + freightType: z.string(), // parent group + bulkCommoditytype: z.string(), isHazardous: z.boolean(), isRefrigerated: z.boolean(), containers: z.array( @@ -171,42 +117,10 @@ export const bookingFormSchema = z (data) => !( data.cargoType === "bulk" && - data.freightType === "bulk" && - !data.bulkCommodity + data.freightType && + !data.bulkCommoditytype ), - { message: "Select a commodity.", path: ["bulkCommodity"] }, - ) - .refine( - (data) => - !( - data.cargoType === "bulk" && - data.freightType === "bulk" && - data.bulkCommodity === "Others" && - !data.bulkCommodityOther.trim() - ), - { message: "Specify the commodity.", path: ["bulkCommodityOther"] }, - ) - .refine( - (data) => - !( - data.cargoType === "bulk" && - data.freightType === "break_bulk" && - !data.breakBulkType - ), - { message: "Select a break-bulk type.", path: ["breakBulkType"] }, - ) - .refine( - (data) => - !( - data.cargoType === "bulk" && - data.freightType === "break_bulk" && - data.breakBulkType === "Others" && - !data.breakBulkTypeOther.trim() - ), - { - message: "Specify the break-bulk type.", - path: ["breakBulkTypeOther"], - }, + { message: "Select a commodity.", path: ["bulkCommoditytype"] }, ) .refine( (data) => { @@ -268,10 +182,7 @@ export const initialBookingFormValues: DeepPartial = { destinationYard: "", shippingLine: "", cargoWeight: "", - bulkCommodity: "", - bulkCommodityOther: "", - breakBulkType: "", - breakBulkTypeOther: "", + bulkCommoditytype: "", isHazardous: false, isRefrigerated: false, containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }], @@ -300,10 +211,7 @@ export const stepFields: Record>> = { "cargoType", "cargoWeight", "freightType", - "bulkCommodity", - "bulkCommodityOther", - "breakBulkType", - "breakBulkTypeOther", + "bulkCommoditytype", "containers", "consolidationEnabled", ], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index ee54196e8..cbd70f5ac 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -44,8 +44,7 @@ export function Step5CargoDetails({ }) { const cargoType = form.watch("cargoType"); const freightType = form.watch("freightType"); - const bulkCommodity = form.watch("bulkCommodity"); - const breakBulkType = form.watch("breakBulkType"); + const bulkCommoditytype = form.watch("bulkCommoditytype"); const containers = form.watch("containers"); const { fields, append, remove } = useFieldArray({ @@ -60,13 +59,21 @@ export function Step5CargoDetails({ ); }, [referenceData]); - const bulkCommodityOptions = useMemo(() => { + const freightTypeGroups = useMemo(() => { if (!referenceData?.cargo_type) return []; - return referenceData.cargo_type.flatMap( - (group) => group.children?.map((c) => c.name) ?? [], + return referenceData.cargo_type.filter( + (g) => g.code !== "CONTAINER", ); }, [referenceData]); + const commodityOptions = useMemo(() => { + if (!referenceData?.cargo_type || !freightType) return []; + const group = referenceData.cargo_type.find( + (g) => g.code.toLowerCase() === freightType, + ); + return group?.children?.map((c) => c.name) ?? []; + }, [referenceData, freightType]); + function getOverweightAlert( type: "20ft" | "40ft", vgm: number, @@ -122,7 +129,7 @@ export function Step5CargoDetails({ selected={cargoType === "container"} onClick={() => { field.onChange("container"); - form.setValue("freightType", undefined, { + form.setValue("freightType", "", { shouldDirty: true, }); }} @@ -194,82 +201,42 @@ export function Step5CargoDetails({ render={({ field, fieldState }) => (
- field.onChange("bulk")} - > -

Bulk

-

- Coffee, fertilizer, grain, ore, etc. -

-
- field.onChange("break_bulk")} - > -

Break-Bulk

-

- Machinery, vehicles, project cargo, etc. -

-
+ {freightTypeGroups.map((group) => { + const val = group.code.toLowerCase(); + return ( + { + field.onChange(val); + form.setValue("bulkCommoditytype", "", { + shouldDirty: true, + }); + }} + > +

{group.name}

+
+ ); + })}
)} /> - {freightType === "bulk" && ( + {freightType && commodityOptions.length > 0 && (
( - {bulkCommodityOptions.map((option) => ( - - {option} - - ))} - - )} - /> - {bulkCommodity === "Others" && ( - ( - - - - - )} - /> - )} -
- )} - - {freightType === "break_bulk" && ( -
- ( - - {bulkCommodityOptions.map((option) => ( + {commodityOptions.map((option) => ( {option} @@ -277,22 +244,6 @@ export function Step5CargoDetails({ )} /> - {breakBulkType === "Others" && ( - ( - - - - - )} - /> - )}
)}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index baca5cd34..672b1f562 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -8,7 +8,11 @@ import type { UpdateFileUploadFieldDto, UpdateFileUploadSettingDto, } from "@/types/fileUploadSettings"; -import { bookingsService, CreateBookingPayload } from "./bookings.service"; +import { + bookingsService, + CreateBookingPayload, + GeneratePriceResponse, +} from "./bookings.service"; import { consignmentsService } from "./consignments.service"; import { trackingService } from "./tracking.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service"; @@ -144,6 +148,31 @@ export const api = { remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) => bookingsService.remove(id), ), + + cancel: endpoint<{ id: string; reason: string }, Freight.IBooking>( + "bookings", + "cancel", + ({ id, reason }) => bookingsService.cancel(id, reason), + ), + + generatePrice: endpoint<{ id: string }, GeneratePriceResponse>( + "bookings", + "generatePrice", + ({ id }) => bookingsService.generatePrice(id), + ), + + submit: endpoint<{ id: string }, Freight.IBooking>( + "bookings", + "submit", + ({ id }) => bookingsService.submit(id), + ), + + uploadDocuments: endpoint< + { id: string; files: Record }, + Freight.IBooking + >("bookings", "uploadDocuments", ({ id, files }) => + bookingsService.uploadDocuments(id, files), + ), }, consignments: { diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 2dc1ba235..c3ea58ad9 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -25,6 +25,21 @@ export interface ContractView { }>; } +export interface PriceLineItem { + code: string; + description: string; + amount: number; + currency: string; +} + +export interface GeneratePriceResponse { + bookingId: string; + totalAmount: number; + currency: string; + lineItems: PriceLineItem[]; + warnings: string[]; +} + export interface SignContractPayload { role: "CUSTOMER" | "STAFF"; signatureImageBase64: string; @@ -53,6 +68,42 @@ export const bookingsService = { await client.delete(`/api/bookings/${id}`); }, + cancel: async (id: string, reason: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/cancel`, { reason }); + return data.data; + }, + + generatePrice: async (id: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/generate-price`); + return data.data; + }, + + submit: async (id: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/submit`); + return data.data; + }, + + uploadDocuments: async ( + id: string, + files: Record, + ): Promise => { + const formData = new FormData(); + for (const [key, fileOrFiles] of Object.entries(files)) { + if (!fileOrFiles) continue; + if (Array.isArray(fileOrFiles)) { + for (const f of fileOrFiles) formData.append(key, f); + } else { + formData.append(key, fileOrFiles); + } + } + const { data } = await client.post( + `/api/bookings/${id}/documents`, + formData, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return data.data; + }, + getContractView: async (id: string): Promise => { const { data } = await client.get(B.CONTRACT_VIEW(id)); return data.data ?? data; diff --git a/apps/edr-freight-web/portal/src/services/files/file_upload_settings.ts b/apps/edr-freight-web/portal/src/services/files/file_upload_settings.ts deleted file mode 100644 index c0ee8b1dc..000000000 --- a/apps/edr-freight-web/portal/src/services/files/file_upload_settings.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { api } from "../crud"; - -import { URL_CONSTANTS } from "../../constants/URLS" \ No newline at end of file From a991139cc26f1dd2a5cb2f057901fb73c4dea731 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 5 Jun 2026 10:23:08 +0300 Subject: [PATCH 4/4] feat(bookings): Integrate auto-pricing into DRAFT booking UI, and refactor customer-company relationships across services. --- apps/edr-freight-api/src/app.module.ts | 5 +- .../train-scheduling.service.ts | 592 ++++++++----- .../src/seed/demo-bookings.seeder.ts | 232 ++--- .../src/seed/pricing-data.seeder.ts | 800 ++++++++++++++++++ .../src/pages/bookings/BookingDetailPage.tsx | 564 ++++++++---- .../portal/src/services/api.ts | 6 - tasks.md | 5 - 7 files changed, 1683 insertions(+), 521 deletions(-) create mode 100644 apps/edr-freight-api/src/seed/pricing-data.seeder.ts delete mode 100644 tasks.md diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index fff9ac3b5..42b5b4dae 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -36,6 +36,7 @@ import { import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; +import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; @Module({ @@ -83,7 +84,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; BackofficeModule, DemoPermissionsModule, ], - providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, FileUploadSettingsSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( @@ -91,6 +92,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly demoBookingsSeeder: DemoBookingsSeeder, + private readonly pricingDataSeeder: PricingDataSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, ) { } @@ -99,6 +101,7 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); await this.demoBookingsSeeder.run(); + await this.pricingDataSeeder.run(); await this.fileUploadSettingsSeeder.run(); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 2a337afef..34c4a7843 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3,25 +3,28 @@ import { ConflictException, Injectable, NotFoundException, -} from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In } from 'typeorm'; +} from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; -import { Booking } from '../bookings/entities/booking.entity'; -import { Locomotive, type LocomotiveStatus } from '../locomotives/entities/locomotive.entity'; -import { LocomotivesRepository } from '../locomotives/locomotives.repository'; -import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; -import { TrainSet } from '../train-sets/entities/train-set.entity'; -import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; -import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; -import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; -import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; -import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; +import { Booking } from "../bookings/entities/booking.entity"; +import { + Locomotive, + type LocomotiveStatus, +} from "../locomotives/entities/locomotive.entity"; +import { LocomotivesRepository } from "../locomotives/locomotives.repository"; +import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity"; +import { TrainSet } from "../train-sets/entities/train-set.entity"; +import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; +import { WagonTypesRepository } from "../wagon-types/wagon-types.repository"; +import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; +import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; +import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; -const DEFAULT_WAGON_TYPE_CODE = 'NW5'; +const DEFAULT_WAGON_TYPE_CODE = "NW5"; const MAX_TRAIN_WEIGHT_TONS = 3500; const MAX_TRAIN_LENGTH_METERS = 760; @@ -74,31 +77,38 @@ export class TrainSchedulingService { private readonly dataSource: DataSource, private readonly locomotivesRepository: LocomotivesRepository, private readonly wagonTypesRepository: WagonTypesRepository, - ) {} + ) { } async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { const bookingRepository = this.dataSource.getRepository(Booking); const queryBuilder = bookingRepository - .createQueryBuilder('booking') - .leftJoinAndSelect('booking.customer', 'customer') - .leftJoinAndSelect('booking.originYard', 'originYard') - .leftJoinAndSelect('booking.destinationYard', 'destinationYard') - .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') - .leftJoinAndSelect('bookingContainer.containerType', 'containerType') - .leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id') - .where('booking.freightType = :freightType', { freightType: 'CONTAINER' }) - .andWhere('scheduleBooking.id IS NULL'); + .createQueryBuilder("booking") + .leftJoinAndSelect("booking.customer", "customer") + .leftJoinAndSelect("booking.originYard", "originYard") + .leftJoinAndSelect("booking.destinationYard", "destinationYard") + .leftJoinAndSelect("booking.bookingContainers", "bookingContainer") + .leftJoinAndSelect("bookingContainer.containerType", "containerType") + .leftJoin( + TrainScheduleBooking, + "scheduleBooking", + "scheduleBooking.booking_id = booking.id", + ) + .where("booking.freightType = :freightType", { freightType: "CONTAINER" }) + .andWhere("scheduleBooking.id IS NULL"); if (query.originStationId) { - queryBuilder.andWhere('booking.originYardId = :originStationId', { + queryBuilder.andWhere("booking.originYardId = :originStationId", { originStationId: query.originStationId, }); } if (query.destinationStationId) { - queryBuilder.andWhere('booking.destinationYardId = :destinationStationId', { - destinationStationId: query.destinationStationId, - }); + queryBuilder.andWhere( + "booking.destinationYardId = :destinationStationId", + { + destinationStationId: query.destinationStationId, + }, + ); } if (query.scheduleDate) { @@ -109,25 +119,44 @@ export class TrainSchedulingService { } if (query.status) { - queryBuilder.andWhere('booking.status = :status', { status: query.status }); + queryBuilder.andWhere("booking.status = :status", { + status: query.status, + }); } const bookings = await queryBuilder - .orderBy('booking.scheduled_date', 'ASC') - .addOrderBy('booking.created_at', 'ASC') + .orderBy("booking.scheduled_date", "ASC") + .addOrderBy("booking.created_at", "ASC") .getMany(); const items: EligibleBookingItem[] = bookings.map((booking) => ({ id: booking.id, reference: booking.reference, - customer: booking.customer?.companyName ?? booking.customer?.email ?? 'Unknown customer', - containerType: booking.bookingContainers - ?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container') - .join(', ') ?? 'Container', - quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0, + customer: + booking.company?.name ?? booking.company?.email ?? "Unknown customer", + containerType: + booking.bookingContainers + ?.map( + (container) => + container.containerType?.label ?? + container.containerType?.code ?? + "Container", + ) + .join(", ") ?? "Container", + quantity: + booking.bookingContainers?.reduce( + (sum, container) => sum + Number(container.quantity ?? 0), + 0, + ) ?? 0, weightTons: this.roundTons(booking.cargoTotalWeightVgm), - origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', - destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', + origin: + booking.originYard?.label ?? + booking.originYard?.code ?? + "Unknown origin", + destination: + booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "Unknown destination", preferredDepartureDate: booking.scheduledDate.toISOString(), status: booking.status, })); @@ -155,7 +184,7 @@ export class TrainSchedulingService { if (!validation.valid) { throw new BadRequestException({ - message: 'train_schedule_invalid', + message: "train_schedule_invalid", violations: validation.violations, }); } @@ -165,92 +194,115 @@ export class TrainSchedulingService { validation.summary.totalWeightTons, ); - const createdSchedule = await this.dataSource.transaction(async (manager) => { - const locomotiveRepository = manager.getRepository(Locomotive); - const lockedLocomotive = await locomotiveRepository.findOne({ - where: { id: locomotive.id }, - lock: { mode: 'pessimistic_write' }, - }); + const createdSchedule = await this.dataSource.transaction( + async (manager) => { + const locomotiveRepository = manager.getRepository(Locomotive); + const lockedLocomotive = await locomotiveRepository.findOne({ + where: { id: locomotive.id }, + lock: { mode: "pessimistic_write" }, + }); - if (!lockedLocomotive) { - throw new NotFoundException(`Locomotive ${locomotive.id} not found`); - } - - if (lockedLocomotive.status !== 'AVAILABLE') { - throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`); - } - - if (Number(lockedLocomotive.maxPullWeightTons) < validation.summary.totalWeightTons) { - throw new BadRequestException( - `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, - ); - } - - const existingScheduleCount = await manager.getRepository(TrainScheduleBooking).count({ - where: { bookingId: In(validation.bookings.map((booking) => booking.id)) }, - }); - - if (existingScheduleCount > 0) { - throw new BadRequestException('One or more bookings are already scheduled'); - } - - const trainSet = await this.buildTrainSet( - manager, - lockedLocomotive, - validation.wagonType, - validation.summary.totalWeightTons, - validation.summary.totalLengthMeters, - validation.wagonPlan, - ); - - const schedule = manager.getRepository(TrainSchedule).create({ - trainSetId: trainSet.id, - originStationId: dto.originStationId, - destinationStationId: dto.destinationStationId, - scheduledDepartureDate: new Date(dto.scheduleDate), - status: 'SCHEDULED', - }); - - const savedSchedule = await manager.getRepository(TrainSchedule).save(schedule); - - const scheduleBookings = validation.bookings.map((booking) => - manager.getRepository(TrainScheduleBooking).create({ - trainScheduleId: savedSchedule.id, - bookingId: booking.id, - }), - ); - await manager.getRepository(TrainScheduleBooking).save(scheduleBookings); - - const savedWagons = await manager.getRepository(TrainSetWagon).find({ - where: { trainSetId: trainSet.id }, - order: { sequenceNo: 'ASC' }, - }); - - const wagonBySequence = new Map(savedWagons.map((wagon) => [wagon.sequenceNo, wagon])); - const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => { - const wagon = wagonBySequence.get(wagonPlan.sequenceNo); - - if (!wagon) { - throw new BadRequestException(`Missing wagon sequence ${wagonPlan.sequenceNo}`); + if (!lockedLocomotive) { + throw new NotFoundException(`Locomotive ${locomotive.id} not found`); } - return wagonPlan.allocations.map((allocation) => - manager.getRepository(WagonBookingAllocation).create({ - trainSetWagonId: wagon.id, - bookingId: allocation.bookingId, - allocatedWeightTons: allocation.allocatedWeightTons, + if (lockedLocomotive.status !== "AVAILABLE") { + throw new ConflictException( + `Locomotive ${lockedLocomotive.code} is not available`, + ); + } + + if ( + Number(lockedLocomotive.maxPullWeightTons) < + validation.summary.totalWeightTons + ) { + throw new BadRequestException( + `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, + ); + } + + const existingScheduleCount = await manager + .getRepository(TrainScheduleBooking) + .count({ + where: { + bookingId: In(validation.bookings.map((booking) => booking.id)), + }, + }); + + if (existingScheduleCount > 0) { + throw new BadRequestException( + "One or more bookings are already scheduled", + ); + } + + const trainSet = await this.buildTrainSet( + manager, + lockedLocomotive, + validation.wagonType, + validation.summary.totalWeightTons, + validation.summary.totalLengthMeters, + validation.wagonPlan, + ); + + const schedule = manager.getRepository(TrainSchedule).create({ + trainSetId: trainSet.id, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + scheduledDepartureDate: new Date(dto.scheduleDate), + status: "SCHEDULED", + }); + + const savedSchedule = await manager + .getRepository(TrainSchedule) + .save(schedule); + + const scheduleBookings = validation.bookings.map((booking) => + manager.getRepository(TrainScheduleBooking).create({ + trainScheduleId: savedSchedule.id, + bookingId: booking.id, }), ); - }); + await manager + .getRepository(TrainScheduleBooking) + .save(scheduleBookings); - await manager.getRepository(WagonBookingAllocation).save(allocationRows); + const savedWagons = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId: trainSet.id }, + order: { sequenceNo: "ASC" }, + }); - await locomotiveRepository.update(lockedLocomotive.id, { - status: 'ASSIGNED', - }); + const wagonBySequence = new Map( + savedWagons.map((wagon) => [wagon.sequenceNo, wagon]), + ); + const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => { + const wagon = wagonBySequence.get(wagonPlan.sequenceNo); - return savedSchedule.id; - }); + if (!wagon) { + throw new BadRequestException( + `Missing wagon sequence ${wagonPlan.sequenceNo}`, + ); + } + + return wagonPlan.allocations.map((allocation) => + manager.getRepository(WagonBookingAllocation).create({ + trainSetWagonId: wagon.id, + bookingId: allocation.bookingId, + allocatedWeightTons: allocation.allocatedWeightTons, + }), + ); + }); + + await manager + .getRepository(WagonBookingAllocation) + .save(allocationRows); + + await locomotiveRepository.update(lockedLocomotive.id, { + status: "ASSIGNED", + }); + + return savedSchedule.id; + }, + ); return this.getContainerTrainScheduleById(createdSchedule); } @@ -261,7 +313,7 @@ export class TrainSchedulingService { const bookingIds = [...new Set(dto.bookingIds)]; if (!bookingIds.length) { - throw new BadRequestException('At least one booking is required'); + throw new BadRequestException("At least one booking is required"); } const [wagonType] = await this.wagonTypesRepository.findAll({ @@ -269,7 +321,9 @@ export class TrainSchedulingService { }); if (!wagonType) { - throw new NotFoundException(`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`); + throw new NotFoundException( + `Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`, + ); } const bookings = await this.loadBookingsForScheduling(bookingIds); @@ -278,21 +332,29 @@ export class TrainSchedulingService { if (bookings.length !== bookingIds.length) { const foundIds = new Set(bookings.map((booking) => booking.id)); const missing = bookingIds.filter((id) => !foundIds.has(id)); - violations.push(`Bookings not found: ${missing.join(', ')}`); + violations.push(`Bookings not found: ${missing.join(", ")}`); } - const scheduledLinks = await this.dataSource.getRepository(TrainScheduleBooking).find({ - where: { bookingId: In(bookingIds) }, - select: { bookingId: true }, - }); + const scheduledLinks = await this.dataSource + .getRepository(TrainScheduleBooking) + .find({ + where: { bookingId: In(bookingIds) }, + select: { bookingId: true }, + }); if (scheduledLinks.length > 0) { - violations.push('One or more selected bookings are already assigned to a train schedule'); + violations.push( + "One or more selected bookings are already assigned to a train schedule", + ); } - const nonContainerBookings = bookings.filter((booking) => booking.freightType !== 'CONTAINER'); + const nonContainerBookings = bookings.filter( + (booking) => booking.freightType !== "CONTAINER", + ); if (nonContainerBookings.length > 0) { - violations.push('Only CONTAINER bookings are supported for train scheduling'); + violations.push( + "Only CONTAINER bookings are supported for train scheduling", + ); } const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate); @@ -302,44 +364,62 @@ export class TrainSchedulingService { booking.destinationYardId !== dto.destinationStationId, ); if (routeMismatch) { - violations.push('Selected bookings must share the same origin and destination as the schedule'); + violations.push( + "Selected bookings must share the same origin and destination as the schedule", + ); } const dateMismatch = bookings.some( (booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey, ); if (dateMismatch) { - violations.push('Selected bookings must share the same schedule date'); + violations.push("Selected bookings must share the same schedule date"); } - const uniqueOriginCount = new Set(bookings.map((booking) => booking.originYardId)).size; + const uniqueOriginCount = new Set( + bookings.map((booking) => booking.originYardId), + ).size; if (uniqueOriginCount > 1) { - violations.push('Selected bookings must share the same origin station'); + violations.push("Selected bookings must share the same origin station"); } - const uniqueDestinationCount = new Set(bookings.map((booking) => booking.destinationYardId)).size; + const uniqueDestinationCount = new Set( + bookings.map((booking) => booking.destinationYardId), + ).size; if (uniqueDestinationCount > 1) { - violations.push('Selected bookings must share the same destination station'); + violations.push( + "Selected bookings must share the same destination station", + ); } const uniqueDateCount = new Set( bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)), ).size; if (uniqueDateCount > 1) { - violations.push('Selected bookings must share the same preferred departure date'); + violations.push( + "Selected bookings must share the same preferred departure date", + ); } const totalWeightTons = this.roundTons( - bookings.reduce((sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), 0), + bookings.reduce( + (sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), + 0, + ), ); - const wagonPlan = this.allocateBookingsToWagons(bookings, this.calculateNW5WagonPlan(totalWeightTons, wagonType)); + const wagonPlan = this.allocateBookingsToWagons( + bookings, + this.calculateNW5WagonPlan(totalWeightTons, wagonType), + ); const totalLengthMeters = this.roundTons( wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0), ); if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) { - violations.push(`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`); + violations.push( + `Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`, + ); } if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) { @@ -357,21 +437,25 @@ export class TrainSchedulingService { ); } - const availableLocomotiveCount = await this.dataSource.getRepository(Locomotive).count({ - where: { status: 'AVAILABLE' as LocomotiveStatus }, - }); + const availableLocomotiveCount = await this.dataSource + .getRepository(Locomotive) + .count({ + where: { status: "AVAILABLE" as LocomotiveStatus }, + }); if (availableLocomotiveCount === 0) { - violations.push('No available locomotive exists for scheduling'); + violations.push("No available locomotive exists for scheduling"); } else { - const capableLocomotives = await this.dataSource.getRepository(Locomotive).find({ - where: { status: 'AVAILABLE' }, - }); + const capableLocomotives = await this.dataSource + .getRepository(Locomotive) + .find({ + where: { status: "AVAILABLE" }, + }); const canPull = capableLocomotives.some( (locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons, ); if (!canPull) { - violations.push('No available locomotive can pull the total weight'); + violations.push("No available locomotive can pull the total weight"); } } @@ -391,14 +475,21 @@ export class TrainSchedulingService { }; } - calculateNW5WagonPlan(totalBookingWeightTons: number, wagonType: WagonType): WagonPlanRecord[] { + calculateNW5WagonPlan( + totalBookingWeightTons: number, + wagonType: WagonType, + ): WagonPlanRecord[] { const wagonCapacityTons = Number(wagonType.capacityTons); const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons); let remainingWeight = this.roundTons(totalBookingWeightTons); return Array.from({ length: wagonsNeeded }, (_, index) => { - const assignedWeightTons = this.roundTons(Math.min(wagonCapacityTons, remainingWeight)); - remainingWeight = this.roundTons(Math.max(0, remainingWeight - assignedWeightTons)); + const assignedWeightTons = this.roundTons( + Math.min(wagonCapacityTons, remainingWeight), + ); + remainingWeight = this.roundTons( + Math.max(0, remainingWeight - assignedWeightTons), + ); return { sequenceNo: index + 1, @@ -410,15 +501,20 @@ export class TrainSchedulingService { }); } - async selectOrValidateLocomotive(locomotiveId: string, totalWeightTons: number) { + async selectOrValidateLocomotive( + locomotiveId: string, + totalWeightTons: number, + ) { const locomotive = await this.locomotivesRepository.findById(locomotiveId); if (!locomotive) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } - if (locomotive.status !== 'AVAILABLE') { - throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); + if (locomotive.status !== "AVAILABLE") { + throw new BadRequestException( + `Locomotive ${locomotive.code} is not available`, + ); } if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { @@ -443,7 +539,7 @@ export class TrainSchedulingService { totalWeightTons, totalLengthMeters, wagonCount: wagonPlan.length, - status: 'ASSIGNED', + status: "ASSIGNED", }); const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet); @@ -463,11 +559,16 @@ export class TrainSchedulingService { return savedTrainSet; } - allocateBookingsToWagons(bookings: Booking[], baseWagonPlan: WagonPlanRecord[]): WagonPlanRecord[] { + allocateBookingsToWagons( + bookings: Booking[], + baseWagonPlan: WagonPlanRecord[], + ): WagonPlanRecord[] { const remaining = bookings.map((booking) => ({ bookingId: booking.id, bookingReference: booking.reference, - remainingWeightTons: this.roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + remainingWeightTons: this.roundTons( + Number(booking.cargoTotalWeightVgm ?? 0), + ), })); let bookingIndex = 0; @@ -496,7 +597,9 @@ export class TrainSchedulingService { booking.remainingWeightTons - allocatedWeightTons, ); wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons); - assignedWeightTons = this.roundTons(assignedWeightTons + allocatedWeightTons); + assignedWeightTons = this.roundTons( + assignedWeightTons + allocatedWeightTons, + ); if (booking.remainingWeightTons <= 0) { bookingIndex += 1; @@ -519,40 +622,54 @@ export class TrainSchedulingService { destinationStation: true, scheduleBookings: true, }, - order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, + order: { scheduledDepartureDate: "DESC", createdAt: "DESC" }, }); return schedules.map((schedule) => ({ id: schedule.id, scheduleDate: schedule.scheduledDepartureDate, - origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + origin: + schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: - schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + null, locomotive: schedule.trainSet?.locomotive ? { - id: schedule.trainSet.locomotive.id, - code: schedule.trainSet.locomotive.code, - name: schedule.trainSet.locomotive.name ?? null, - } + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name ?? null, + } : null, wagonCount: schedule.trainSet?.wagonCount ?? 0, - totalWeightTons: this.roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), - totalLengthMeters: this.roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), + totalWeightTons: this.roundTons( + Number(schedule.trainSet?.totalWeightTons ?? 0), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet?.totalLengthMeters ?? 0), + ), bookingsCount: schedule.scheduleBookings?.length ?? 0, status: schedule.status, })); } async getContainerTrainScheduleById(id: string) { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id }, - relations: { - trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } } }, - originStation: true, - destinationStation: true, - scheduleBookings: { booking: { customer: true, originYard: true, destinationYard: true } }, - }, - }); + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ + where: { id }, + relations: { + trainSet: { + locomotive: true, + wagons: { wagonType: true, allocations: { booking: true } }, + }, + originStation: true, + destinationStation: true, + scheduleBookings: { + booking: { company: true, originYard: true, destinationYard: true }, + }, + }, + }); if (!schedule) { throw new NotFoundException(`Train schedule ${id} not found`); @@ -567,67 +684,78 @@ export class TrainSchedulingService { destinationStation: schedule.destinationStation, trainSet: schedule.trainSet ? { - id: schedule.trainSet.id, - status: schedule.trainSet.status, - wagonCount: schedule.trainSet.wagonCount, - totalWeightTons: this.roundTons(Number(schedule.trainSet.totalWeightTons)), - totalLengthMeters: this.roundTons(Number(schedule.trainSet.totalLengthMeters)), - locomotive: schedule.trainSet.locomotive - ? { - id: schedule.trainSet.locomotive.id, - code: schedule.trainSet.locomotive.code, - name: schedule.trainSet.locomotive.name, - status: schedule.trainSet.locomotive.status, - maxPullWeightTons: this.roundTons( - Number(schedule.trainSet.locomotive.maxPullWeightTons), - ), + id: schedule.trainSet.id, + status: schedule.trainSet.status, + wagonCount: schedule.trainSet.wagonCount, + totalWeightTons: this.roundTons( + Number(schedule.trainSet.totalWeightTons), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet.totalLengthMeters), + ), + locomotive: schedule.trainSet.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name, + status: schedule.trainSet.locomotive.status, + maxPullWeightTons: this.roundTons( + Number(schedule.trainSet.locomotive.maxPullWeightTons), + ), + } + : null, + wagons: [...(schedule.trainSet.wagons ?? [])] + .sort((left, right) => left.sequenceNo - right.sequenceNo) + .map((wagon) => ({ + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: this.roundTons(Number(wagon.capacityTons)), + lengthMeters: this.roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: this.roundTons( + Number(wagon.assignedWeightTons), + ), + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, } - : null, - wagons: - [...(schedule.trainSet.wagons ?? [])] - .sort((left, right) => left.sequenceNo - right.sequenceNo) - .map((wagon) => ({ - id: wagon.id, - sequenceNo: wagon.sequenceNo, - capacityTons: this.roundTons(Number(wagon.capacityTons)), - lengthMeters: this.roundTons(Number(wagon.lengthMeters)), - assignedWeightTons: this.roundTons(Number(wagon.assignedWeightTons)), - wagonType: wagon.wagonType - ? { - id: wagon.wagonType.id, - code: wagon.wagonType.code, - name: wagon.wagonType.name, - } - : null, - allocations: - wagon.allocations?.map((allocation) => ({ - id: allocation.id, - bookingId: allocation.bookingId, - bookingReference: allocation.booking?.reference ?? null, - allocatedWeightTons: this.roundTons(Number(allocation.allocatedWeightTons)), - })) ?? [], - })), - } + : null, + allocations: + wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: this.roundTons( + Number(allocation.allocatedWeightTons), + ), + })) ?? [], + })), + } : null, bookings: schedule.scheduleBookings?.map((scheduleBooking) => ({ id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId, reference: scheduleBooking.booking?.reference ?? null, customer: - scheduleBooking.booking?.customer?.companyName ?? - scheduleBooking.booking?.customer?.email ?? + scheduleBooking.booking?.company?.name ?? + scheduleBooking.booking?.company?.email ?? null, - weightTons: this.roundTons(Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0)), + weightTons: this.roundTons( + Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0), + ), status: scheduleBooking.booking?.status ?? null, })) ?? [], }; } async cancelTrainSchedule(id: string) { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id }, - relations: { trainSet: { locomotive: true } }, - }); + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ + where: { id }, + relations: { trainSet: { locomotive: true } }, + }); if (!schedule) { throw new NotFoundException(`Train schedule ${id} not found`); @@ -635,19 +763,21 @@ export class TrainSchedulingService { await this.dataSource.transaction(async (manager) => { await manager.getRepository(TrainSchedule).update(schedule.id, { - status: 'CANCELLED', + status: "CANCELLED", }); if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { - status: 'CANCELLED', + status: "CANCELLED", }); } if (schedule.trainSet?.locomotiveId) { - await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, { - status: 'AVAILABLE', - }); + await manager + .getRepository(Locomotive) + .update(schedule.trainSet.locomotiveId, { + status: "AVAILABLE", + }); } }); @@ -658,12 +788,12 @@ export class TrainSchedulingService { return this.dataSource.getRepository(Booking).find({ where: { id: In(bookingIds) }, relations: { - customer: true, + company: true, originYard: true, destinationYard: true, bookingContainers: { containerType: true }, }, - order: { createdAt: 'ASC' }, + order: { createdAt: "ASC" }, }); } @@ -673,7 +803,7 @@ export class TrainSchedulingService { } private roundTons(value: number | string | null | undefined) { - const numericValue = typeof value === 'number' ? value : Number(value ?? 0); + const numericValue = typeof value === "number" ? value : Number(value ?? 0); if (!Number.isFinite(numericValue)) { return 0; diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 507040eb0..e215dbd9b 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -1,95 +1,105 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { randomUUID } from 'crypto'; -import { DataSource } from 'typeorm'; +import { Injectable, Logger } from "@nestjs/common"; +import { randomUUID } from "crypto"; +import { DataSource } from "typeorm"; -import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; -import { Booking } from '../modules/bookings/entities/booking.entity'; -import { Customer } from '../modules/customers/entities/customer.entity'; -import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; -import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; -import { Yard } from '../modules/rule-engine/entities/yard.entity'; -import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; -import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { BookingContainer } from "../modules/bookings/entities/booking-container.entity"; +import { Booking } from "../modules/bookings/entities/booking.entity"; +import { Customer } from "../modules/customers/entities/customer.entity"; +import { Locomotive } from "../modules/locomotives/entities/locomotive.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; +import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; -const SEED_FLAG = 'SEED_DEMO_BOOKINGS'; +const SEED_FLAG = "SEED_DEMO_BOOKINGS"; -const SERVICE_TYPE_CODE = 'RAIL_CONTAINER'; -const CUSTOMER_EMAIL = 'train-scheduling-demo@edr.local'; +const SERVICE_TYPE_CODE = "RAIL_CONTAINER"; +const CUSTOMER_EMAIL = "train-scheduling-demo@edr.local"; const YARDS = [ - { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, - { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, - { code: 'DIRE_DAWA', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 3 }, + { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + }, ]; const CONTAINER_TYPES = [ - { code: '20FT', label: '20FT', sizeFt: 20 }, - { code: '40FT', label: '40FT', sizeFt: 40 }, + { code: "20FT", label: "20FT", sizeFt: 20 }, + { code: "40FT", label: "40FT", sizeFt: 40 }, ]; const DEMO_BOOKINGS = [ { - reference: 'BKG-CONT-001', - containerCode: '40FT', + reference: "BKG-CONT-001", + containerCode: "40FT", quantity: 20, totalWeightTons: 500, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-002', - containerCode: '20FT', + reference: "BKG-CONT-002", + containerCode: "20FT", quantity: 10, totalWeightTons: 300, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-003', - containerCode: '40FT', + reference: "BKG-CONT-003", + containerCode: "40FT", quantity: 15, totalWeightTons: 450, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-007', - containerCode: '20FT', + reference: "BKG-CONT-007", + containerCode: "20FT", quantity: 6, totalWeightTons: 180, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-004', - containerCode: '40FT', + reference: "BKG-CONT-004", + containerCode: "40FT", quantity: 12, totalWeightTons: 360, - originCode: 'ADDIS_ABABA', - destinationCode: 'DIRE_DAWA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "ADDIS_ABABA", + destinationCode: "DIRE_DAWA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-005', - containerCode: '20FT', + reference: "BKG-CONT-005", + containerCode: "20FT", quantity: 8, totalWeightTons: 160, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-21T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-21T08:00:00.000Z", }, { - reference: 'BKG-CONT-006', - containerCode: '40FT', + reference: "BKG-CONT-006", + containerCode: "40FT", quantity: 80, totalWeightTons: 3600, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, ]; @@ -97,24 +107,26 @@ const DEMO_BOOKINGS = [ export class DemoBookingsSeeder { private readonly logger = new Logger(DemoBookingsSeeder.name); - constructor(private readonly dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) { } async run() { - const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === 'true'; + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; if (!shouldSeed) { - this.logger.log(`Skipping demo booking seed because ${SEED_FLAG} is not enabled`); + this.logger.log( + `Skipping demo booking seed because ${SEED_FLAG} is not enabled`, + ); return; } await this.dataSource.transaction(async (manager) => { await manager.getRepository(WagonType).upsert( { - code: 'NW5', - name: 'Flat Wagon', + code: "NW5", + name: "Flat Wagon", capacityTons: 70, lengthMeters: 14, maxWagonsPerTrain: 53, - supportedLoadTypes: ['CONTAINER'], + supportedLoadTypes: ["CONTAINER"], isActive: true, }, { conflictPaths: { code: true } }, @@ -123,16 +135,16 @@ export class DemoBookingsSeeder { await manager.getRepository(Locomotive).upsert( [ { - code: 'LOC-001', - name: 'Demo Locomotive 1', + code: "LOC-001", + name: "Demo Locomotive 1", maxPullWeightTons: 3500, - status: 'AVAILABLE', + status: "AVAILABLE", }, { - code: 'LOC-002', - name: 'Demo Locomotive 2', + code: "LOC-002", + name: "Demo Locomotive 2", maxPullWeightTons: 2500, - status: 'AVAILABLE', + status: "AVAILABLE", }, ], { conflictPaths: { code: true } }, @@ -146,8 +158,8 @@ export class DemoBookingsSeeder { await manager.getRepository(ServiceType).upsert( { code: SERVICE_TYPE_CODE, - serviceName: 'Rail Container Service', - description: 'Temporary service type for train scheduling demos', + serviceName: "Rail Container Service", + description: "Temporary service type for train scheduling demos", canBeBookedAlone: true, includesFirstMile: false, includesLastMile: false, @@ -173,74 +185,86 @@ export class DemoBookingsSeeder { await manager.getRepository(Customer).upsert( { - userId: '00000000-0000-0000-0000-000000000111', - firstName: 'Train', - lastName: 'Scheduling', + userId: "00000000-0000-0000-0000-000000000111", + firstName: "Train", + lastName: "Scheduling", email: CUSTOMER_EMAIL, - phone: '251900000001', - companyName: 'Train Scheduling Demo Customer', + phone: "251900000001", + companyName: "Train Scheduling Demo Customer", companyEmail: CUSTOMER_EMAIL, - companyPhone: '251900000001', - companyLocation: 'Addis Ababa', - companyAddress: 'Demo Address', - customerType: 'DEMO', - status: 'ACTIVE', - contactPersonName: 'Train Scheduling', - contactPersonPhone: '251900000001', - tinNumber: '1234567890', - vatNumber: '1234567890', - fanNumber: '1234567890123456', - generalManagerName: 'Demo Manager', + companyPhone: "251900000001", + companyLocation: "Addis Ababa", + companyAddress: "Demo Address", + customerType: "DEMO", + status: "ACTIVE", + contactPersonName: "Train Scheduling", + contactPersonPhone: "251900000001", + tinNumber: "1234567890", + vatNumber: "1234567890", + fanNumber: "1234567890123456", + generalManagerName: "Demo Manager", generalManagerEmail: CUSTOMER_EMAIL, - generalManagerPhone: '251900000001', + generalManagerPhone: "251900000001", }, { conflictPaths: { email: true } }, ); const [serviceType, customer, yards, containerTypes] = await Promise.all([ - manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), - manager.getRepository(Customer).findOneByOrFail({ email: CUSTOMER_EMAIL }), + manager + .getRepository(ServiceType) + .findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager + .getRepository(Customer) + .findOneByOrFail({ email: CUSTOMER_EMAIL }), manager.getRepository(Yard).find(), manager.getRepository(ContainerType).find(), ]); const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); const containerTypeByCode = new Map( - containerTypes.map((containerType) => [containerType.code, containerType]), + containerTypes.map((containerType) => [ + containerType.code, + containerType, + ]), ); for (const demoBooking of DEMO_BOOKINGS) { const origin = yardByCode.get(demoBooking.originCode); const destination = yardByCode.get(demoBooking.destinationCode); - const containerType = containerTypeByCode.get(demoBooking.containerCode); + const containerType = containerTypeByCode.get( + demoBooking.containerCode, + ); if (!origin || !destination || !containerType) { - throw new Error(`demo_booking_seed_dependency_missing:${demoBooking.reference}`); + throw new Error( + `demo_booking_seed_dependency_missing:${demoBooking.reference}`, + ); } - const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + const vgmPerUnitTons = + demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( { reference: demoBooking.reference, - customerId: customer.id, - status: 'APPROVED', + companyId: customer.id, + status: "APPROVED", scheduledDate: new Date(demoBooking.scheduledDate), totalAmount: 0, - paymentStatus: 'PENDING', - contractType: 'NEW', + paymentStatus: "PENDING", + contractType: "NEW", serviceTypeId: serviceType.id, - equipmentReturn: 'WITHOUT_RETURN', + equipmentReturn: "WITHOUT_RETURN", originYardId: origin.id, destinationYardId: destination.id, - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', + tradeDirection: "IMPORT", + freightType: "CONTAINER", cargoTypeId: null, cargoFreeText: null, shippingLineId: null, cargoTotalWeightVgm: demoBooking.totalWeightTons, isHazardous: false, - paymentCurrency: 'USD', + paymentCurrency: "USD", allowConsolidation: false, priorityScore: 0, versionNumber: 1, @@ -252,7 +276,9 @@ export class DemoBookingsSeeder { reference: demoBooking.reference, }); - await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager + .getRepository(BookingContainer) + .delete({ bookingId: booking.id }); await manager.getRepository(BookingContainer).insert({ id: randomUUID(), bookingId: booking.id, @@ -264,11 +290,13 @@ export class DemoBookingsSeeder { weightLimitRuleId: null, isOverweight: demoBooking.totalWeightTons > 70, overweightExcessTons: - demoBooking.totalWeightTons > 70 ? demoBooking.totalWeightTons - 70 : null, + demoBooking.totalWeightTons > 70 + ? demoBooking.totalWeightTons - 70 + : null, }); } }); - this.logger.log('Seeded demo train scheduling data'); + this.logger.log("Seeded demo train scheduling data"); } } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts new file mode 100644 index 000000000..671436a73 --- /dev/null +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -0,0 +1,800 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity"; +import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; +import { PriorityRule } from "../modules/rule-engine/entities/priority-rule.entity"; +import { Rate } from "../modules/rule-engine/entities/rate.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity"; +import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity"; +import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; + +const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001"; +const CEO_USER_ID = "00000000-0000-0000-0000-000000000002"; + +@Injectable() +export class PricingDataSeeder { + private readonly logger = new Logger(PricingDataSeeder.name); + + constructor(private readonly dataSource: DataSource) { } + + async run(): Promise { + await this.dataSource.transaction(async (manager) => { + const ctRepo = manager.getRepository(ContainerType); + const stRepo = manager.getRepository(ServiceType); + const yRepo = manager.getRepository(Yard); + const slRepo = manager.getRepository(ShippingLine); + const wlRepo = manager.getRepository(WeightLimitRule); + const prRepo = manager.getRepository(PriorityRule); + const rRepo = manager.getRepository(Rate); + + await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo); + await this.seedWeightLimits(wlRepo, ctRepo); + await this.seedPriorityRules(prRepo); + const containerTypes = await ctRepo.find(); + const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct])); + + const rates = await this.seedRates(rRepo, ctByCode); + const ratesByType = new Map(); + for (const r of rates) { + const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`; + if (!ratesByType.has(key)) ratesByType.set(key, []); + ratesByType.get(key)!.push(r); + } + + await this.seedSurchargeTypes(manager, ratesByType); + + const yards = await yRepo.find(); + const yardByCode = new Map(yards.map((y) => [y.code, y])); + const serviceTypes = await stRepo.find(); + const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); + const shippingLines = await slRepo.find(); + const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl])); + const cargoTypes = await manager.getRepository(CargoType).find(); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + + await this.seedDraftBookings( + ctByCode, + yardByCode, + stByCode, + slByCode, + cargoByCode, + ); + }); + + this.logger.log("Seeded pricing data"); + } + + private async upsertReferenceData( + manager: any, + ctRepo: any, + stRepo: any, + yRepo: any, + slRepo: any, + ): Promise { + await yRepo.upsert( + [ + { + code: "DJIBOUTI", + label: "Djibouti", + country: "Djibouti", + displayOrder: 1, + isActive: true, + }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + isActive: true, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + isActive: true, + }, + { + code: "MODJO", + label: "Modjo", + country: "Ethiopia", + displayOrder: 4, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await ctRepo.upsert( + [ + { + code: "20FT", + label: "20FT Standard", + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }, + { + code: "40FT", + label: "40FT Standard", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }, + { + code: "20FT_REEFER", + label: "20FT Reefer", + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 3, + }, + { + code: "40FT_REEFER", + label: "40FT Reefer", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 4, + }, + ], + { conflictPaths: { code: true } }, + ); + + await stRepo.upsert( + [ + { + code: "RAIL_CONTAINER", + serviceName: "Rail Container Service", + description: "Standard rail container transport", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { + code: "RAIL_FORWARDING", + serviceName: "Rail Forwarding Service", + description: "Rail transport with first/last mile and customs", + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: true, + priorityBonusPoints: 100, + isActive: true, + displayOrder: 2, + }, + { + code: "RAIL_BULK", + serviceName: "Rail Bulk Transport", + description: "Bulk commodity rail transport", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 50, + isActive: true, + displayOrder: 3, + }, + ], + { conflictPaths: { code: true } }, + ); + + await slRepo.upsert( + [ + { + code: "MAERSK", + label: "Maersk Line", + mappedToCode: "MAERSK", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "MSC", + label: "MSC", + mappedToCode: "MSC", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "CMA_CGM", + label: "CMA CGM", + mappedToCode: "CMA_CGM", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "COSCO", + label: "COSCO Shipping", + mappedToCode: "COSCO", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "OTHER", + label: "Other Line", + mappedToCode: null, + showExtraFeeNotice: false, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(CargoType).upsert( + [ + { + code: "GRAIN", + cargoTypeName: "Grain / Cereals", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 1, + }, + { + code: "FERTILIZER", + cargoTypeName: "Fertilizer", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 2, + }, + { + code: "CEMENT", + cargoTypeName: "Cement / Clinker", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 3, + }, + { + code: "STEEL", + cargoTypeName: "Steel / Rebar", + requiresDirectorApproval: true, + isActive: true, + displayOrder: 4, + }, + { + code: "MACHINERY", + cargoTypeName: "Heavy Machinery", + requiresDirectorApproval: true, + isActive: true, + displayOrder: 5, + }, + { + code: "OTHER_BULK", + cargoTypeName: "Other Bulk Cargo", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 6, + }, + ], + { conflictPaths: { code: true } }, + ); + } + + private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { + await wlRepo.createQueryBuilder().delete().execute(); + const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); + const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); + const base = new Date("2026-01-01"); + await wlRepo.insert([ + { + containerTypeId: twenty.id, + tradeDirection: "IMPORT", + maxVgmTons: 26, + effectiveFrom: base, + }, + { + containerTypeId: twenty.id, + tradeDirection: "EXPORT", + maxVgmTons: 26, + effectiveFrom: base, + }, + { + containerTypeId: forty.id, + tradeDirection: "IMPORT", + maxVgmTons: 28, + effectiveFrom: base, + }, + { + containerTypeId: forty.id, + tradeDirection: "EXPORT", + maxVgmTons: 28, + effectiveFrom: base, + }, + ]); + this.logger.log("Seeded weight limit rules"); + } + + private async seedPriorityRules(prRepo: any): Promise { + const existing = await prRepo.find({ + where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }], + }); + for (const r of existing) { + await prRepo.remove(r); + } + await prRepo.save([ + prRepo.create({ + code: "USD_PRIORITY", + label: "USD Payment Priority", + score: 200, + conditionCurrency: "USD", + isActive: true, + }), + prRepo.create({ + code: "STANDARD_PRIORITY", + label: "Standard Priority", + score: 50, + conditionCurrency: null, + isActive: true, + }), + ]); + this.logger.log("Seeded priority rules"); + } + + private async seedRates( + rRepo: any, + ctByCode: Map, + ): Promise { + const effectiveFrom = new Date("2026-01-01"); + const now = new Date(); + const rateData = [ + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 800, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 1200, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 45000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 67000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 600, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 900, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 34000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 50000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 20000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 30000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 1000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 56000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 750, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 42000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: null, + currency: "ETB", + rateValue: 25000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "BULK_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_IMPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 2800, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 40, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_EXPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 2200, + rateUnit: "PER_TON", + }, + { + rateType: "OVERWEIGHT_PER_TON", + containerTypeId: null, + currency: "USD", + rateValue: 25, + rateUnit: "PER_TON", + }, + { + rateType: "OVERWEIGHT_PER_TON", + containerTypeId: null, + currency: "ETB", + rateValue: 1400, + rateUnit: "PER_TON", + }, + { + rateType: "HAZARD_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 150, + rateUnit: "FLAT", + }, + { + rateType: "HAZARD_SURCHARGE", + containerTypeId: null, + currency: "ETB", + rateValue: 8500, + rateUnit: "FLAT", + }, + { + rateType: "REEFER_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 200, + rateUnit: "FLAT", + }, + { + rateType: "REEFER_SURCHARGE", + containerTypeId: null, + currency: "ETB", + rateValue: 11000, + rateUnit: "FLAT", + }, + { + rateType: "DOUBLE_HANDLING", + containerTypeId: null, + currency: "USD", + rateValue: 100, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "DOUBLE_HANDLING", + containerTypeId: null, + currency: "ETB", + rateValue: 5500, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "LASHING", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "LASHING", + containerTypeId: null, + currency: "ETB", + rateValue: 2800, + rateUnit: "PER_CONTAINER", + }, + ]; + + const entities = rateData.map((d) => + rRepo.create({ + ...d, + status: "LIVE", + proposedByStaffId: STAFF_USER_ID, + approvedByCeoId: CEO_USER_ID, + approvedAt: now, + effectiveFrom, + }), + ); + return rRepo.save(entities); + } + + private async seedSurchargeTypes( + manager: any, + ratesByType: Map, + ): Promise { + const surRepo = manager.getRepository(SurchargeType); + const bcmRepo = manager.getRepository(BookingCargoModifier); + await bcmRepo.createQueryBuilder().delete().execute(); + const findRate = (rateType: string, currency: string) => { + const key = `${rateType}|${currency}|`; + const rates = ratesByType.get(key); + return rates?.[0]; + }; + + const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD"); + const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB"); + const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD"); + const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB"); + const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD"); + const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB"); + const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD"); + const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB"); + const consolidRateUsd = findRate("LASHING", "USD"); + const consolidRateEtb = findRate("LASHING", "ETB"); + + await surRepo.createQueryBuilder().delete().execute(); + await surRepo.save([ + surRepo.create({ + code: "HAZARDOUS_CARGO", + label: "Hazardous Cargo", + triggerCondition: "CARGO_FLAG_HAZARDOUS", + rateId: hazardRateUsd?.id ?? hazardRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "REEFER_CARGO", + label: "Reefer Cargo", + triggerCondition: "CARGO_FLAG_REEFER", + rateId: reeferRateUsd?.id ?? reeferRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "OVERWEIGHT_CARGO", + label: "Overweight Cargo", + triggerCondition: "VGM_EXCEEDS_LIMIT", + rateId: overweightRateUsd?.id ?? overweightRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "SHIPPING_LINE_FEE", + label: "Shipping Line Fee", + triggerCondition: "SHIPPING_LINE_MAPPED", + rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "CONSOLIDATION_FEE", + label: "Consolidation Fee", + triggerCondition: "CONSOLIDATION_ENABLED", + rateId: consolidRateUsd?.id ?? consolidRateEtb?.id, + isActive: true, + }), + ]); + this.logger.log("Seeded surcharge types"); + } + + private async seedDraftBookings( + ctByCode: Map, + yardByCode: Map, + stByCode: Map, + slByCode: Map, + cargoByCode: Map, + ): Promise { + const djibouti = yardByCode.get("DJIBOUTI")!; + const addis = yardByCode.get("ADDIS_ABABA")!; + const railContainer = stByCode.get("RAIL_CONTAINER")!; + const railBulk = stByCode.get("RAIL_BULK")!; + const maersk = slByCode.get("MAERSK")!; + const grain = cargoByCode.get("GRAIN")!; + const twenty = ctByCode.get("20FT")!; + const forty = ctByCode.get("40FT")!; + const twentyReefer = ctByCode.get("20FT_REEFER")!; + + const drafts = [ + { + reference: "BKG-PRICE-001", + description: "Standard 20FT container import — base rail only", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 250, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-002", + description: "40FT container import + hazardous surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: true, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 135, + containers: [ + { containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["HAZARDOUS_CARGO"], + }, + { + reference: "BKG-PRICE-003", + description: "20FT container import + shipping line (ETB)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "ETB", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: maersk.id, + cargoTypeId: null, + cargoTotalWeightVgm: 480, + containers: [ + { containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 }, + ], + expectedBaseRate: 45000, + expectedSurcharges: ["SHIPPING_LINE_FEE"], + }, + { + reference: "BKG-PRICE-004", + description: "40FT container import + consolidation (USD)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: true, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 224, + containers: [ + { containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["CONSOLIDATION_FEE"], + }, + { + reference: "BKG-PRICE-005", + description: "Bulk import — grain", + freightType: "BULK" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railBulk.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: grain.id, + cargoTotalWeightVgm: 500, + containers: [], + expectedBaseRate: 50, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-006", + description: "20FT reefer container import + reefer surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 75, + containers: [ + { containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["REEFER_CARGO"], + }, + { + reference: "BKG-PRICE-007", + description: "20FT container import + overweight (30t > 26t limit)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 300, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["OVERWEIGHT_CARGO"], + }, + ]; + + this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`); + } +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index 73977fc7c..07243cdec 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -16,7 +16,6 @@ import { ShieldCheck, AlertTriangle, Info, - Clock, Layers, CheckCircle2, History, @@ -36,7 +35,6 @@ import { import Breadcrumbs from "@/components/Breadcrumbs"; import { api } from "@/services/api"; import type { Freight } from "@edr/types"; -import type { GeneratePriceResponse } from "@/services/bookings.service"; import { Card, CardHeader, @@ -46,6 +44,14 @@ import { Badge, Button, Separator, + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, } from "@edr/ui-common"; import { cn } from "@/lib/utils"; import useAuth from "@/hooks/useAuth"; @@ -57,12 +63,40 @@ const PROGRESS_STAGES = [ { label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] }, ]; -const STATUS_MAP: Record = { - DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 }, - CONFIRMED: { title: "Booking Confirmed", description: "Booking has been confirmed and approved.", color: "text-emerald-600", stage: 1 }, - IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 2 }, - DELIVERED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 3 }, - CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 }, +const STATUS_MAP: Record< + string, + { title: string; description: string; color: string; stage: number } +> = { + DRAFT: { + title: "Drafting Request", + description: "Booking is being prepared and has not been submitted.", + color: "text-slate-500", + stage: 0, + }, + CONFIRMED: { + title: "Booking Confirmed", + description: "Booking has been confirmed and approved.", + color: "text-emerald-600", + stage: 1, + }, + IN_TRANSIT: { + title: "Cargo Moving", + description: "Shipment is currently moving through the rail network.", + color: "text-sky-600", + stage: 2, + }, + DELIVERED: { + title: "Service Complete", + description: "Cargo delivered and service successfully terminated.", + color: "text-emerald-600", + stage: 3, + }, + CANCELLED: { + title: "Cancelled", + description: "This booking process has been terminated.", + color: "text-red-600", + stage: -1, + }, }; const REQUIRED_DOC_FIELDS = [ @@ -74,10 +108,14 @@ const REQUIRED_DOC_FIELDS = [ export default function BookingDetailPage() { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const queryClient = useQueryClient(); - const { data: booking, isLoading, isError, error } = useQuery( + const { + data: booking, + isLoading, + isError, + error, + } = useQuery( api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id, @@ -85,7 +123,9 @@ export default function BookingDetailPage() { ); const refetchBooking = () => { - queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: id! }), + }); }; if (isLoading) { @@ -93,7 +133,9 @@ export default function BookingDetailPage() {
-

Loading booking details…

+

+ Loading booking details… +

); @@ -110,7 +152,9 @@ export default function BookingDetailPage() { Failed to load booking

- {error instanceof Error ? error.message : "An unexpected error occurred."} + {error instanceof Error + ? error.message + : "An unexpected error occurred."}

@@ -133,7 +177,9 @@ export default function BookingDetailPage() { } if (booking.status === "DRAFT") { - return ; + return ( + + ); } return ; @@ -151,20 +197,20 @@ function DraftBookingView({ const { customer } = useAuth(); const fileInputRefs = useRef>({}); - const [pricingData, setPricingData] = useState(null); - const [selectedFiles, setSelectedFiles] = useState>({}); + const [selectedFiles, setSelectedFiles] = useState< + Record + >({}); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); const [cancelReason, setCancelReason] = useState(""); const anyFileSelected = Object.values(selectedFiles).some(Boolean); - const allDocsProvided = !anyFileSelected; - const priceMutation = useMutation({ - mutationFn: () => api.bookings.generatePrice.call({ id: booking.id }), - onSuccess: (data) => { - setPricingData(data); - queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: booking.id }) }); - }, - }); + const pricingQuery = useQuery( + api.bookings.generatePrice.queryOptions({ + input: { id: booking.id }, + enabled: !!booking.id, + }), + ); const uploadMutation = useMutation({ mutationFn: (files: Record) => @@ -187,6 +233,7 @@ function DraftBookingView({ mutationFn: (reason: string) => api.bookings.cancel.call({ id: booking.id, reason }), onSuccess: () => { + setCancelDialogOpen(false); onBookingUpdated(); }, }); @@ -211,17 +258,19 @@ function DraftBookingView({ cancelMutation.mutate(reason); } - const canConfirm = !!pricingData && !uploadMutation.isPending && !submitMutation.isPending; + const canConfirm = + pricingQuery.isSuccess && !uploadMutation.isPending && !submitMutation.isPending; const companyName = (customer as any)?.company?.name ?? "—"; const companyTin = (customer as any)?.company?.tin ?? "—"; const contactName = (customer as any)?.profile - ? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || "—" + ? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || + "—" : "—"; const contactEmail = (customer as any)?.profile?.email ?? "—"; return ( -
+
- {priceMutation.isError && ( + {pricingQuery.isError && (

Pricing failed

- {priceMutation.error instanceof Error - ? priceMutation.error.message + {pricingQuery.error instanceof Error + ? pricingQuery.error.message : "An unexpected error occurred."}

@@ -307,50 +356,85 @@ function DraftBookingView({
)} - + Pricing Estimation - Generate a price estimate based on your booking details. + Price estimate based on your booking details. - {pricingData ? ( + {pricingQuery.isLoading ? ( +
+ +

+ Calculating price… +

+
+ ) : pricingQuery.isError ? ( +
+

+ Could not calculate price. +

+ +
+ ) : pricingQuery.data ? (
- + - {pricingData.lineItems.map((item, i) => ( + {pricingQuery.data.lineItems.map((item, i) => ( - + ))} - +
DescriptionAmount + Amount +
{item.description} + {item.description} + {item.amount.toLocaleString()} {item.currency}
Total Estimated Cost + Total Estimated Cost + - {pricingData.totalAmount.toLocaleString()} {pricingData.currency} + {pricingQuery.data.totalAmount.toLocaleString()}{" "} + {pricingQuery.data.currency}
- {pricingData.warnings.length > 0 && ( + {pricingQuery.data.warnings.length > 0 && (
- {pricingData.warnings.map((w, i) => ( -

+ {pricingQuery.data.warnings.map((w, i) => ( +

{w}

@@ -362,40 +446,13 @@ function DraftBookingView({
- ) : ( -
-
- -
-
-

No pricing yet

-

- Generate a price estimate to review before submitting. -

-
- -
- )} + ) : null}
@@ -406,8 +463,8 @@ function DraftBookingView({ Required Documents - Provide the necessary documents for this booking. Some information is - pre-filled from your company profile. + Provide the necessary documents for this booking. Some information + is pre-filled from your company profile. @@ -460,7 +517,10 @@ function DraftBookingView({ accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={(e) => { - handleFileSelect(doc.key, e.target.files?.[0] ?? null); + handleFileSelect( + doc.key, + e.target.files?.[0] ?? null, + ); }} /> {selectedFiles[doc.key] && ( + +

+ Cancelling will terminate this booking request and cannot be + undone. +

+ + + + + + + Cancel Booking + + Are you sure you want to cancel this booking? This action + cannot be undone. + + +
+ + setCancelReason(e.target.value)} + autoFocus + /> +
+ + + + + + +
+
@@ -556,9 +652,11 @@ function DraftBookingView({
{!canConfirm && (

- {!pricingData - ? "Request pricing estimation before confirming." - : "Upload documents before confirming."} + {pricingQuery.isLoading + ? "Calculating price…" + : pricingQuery.isError + ? "Price calculation failed." + : "Upload documents before confirming."}

)} - - - )} + {(booking.status === "CONFIRMED" || + booking.status === "IN_TRANSIT") && ( + + +
+

Contract ready

+

+ Review the agreement and apply your digital signature. +

+
+ +
+
+ )} @@ -658,14 +756,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { Booking Status Lifecycle - Track the journey from request to completion + + Track the journey from request to completion +
= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }} + style={{ + width: + currentStageIndex >= 0 + ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` + : "0%", + }} />
@@ -674,19 +779,32 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const isActive = idx === currentStageIndex; return ( -
-
- {isCompleted ? : } +
+
+ {isCompleted ? ( + + ) : ( + + )}
- + {stage.label}
@@ -695,26 +813,40 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
-
- {normalizedStatus === "CANCELLED" ? : } -
-
-

- {statusConfig.title} -

-

- {statusConfig.description} -

-
- {normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && ( -
+
+ {normalizedStatus === "CANCELLED" ? ( + + ) : ( + + )} +
+
+

+ {statusConfig.title} +

+

+ {statusConfig.description} +

+
+ {normalizedStatus !== "CANCELLED" && + normalizedStatus !== "DELIVERED" && ( +
-

Est. Waiting

-

1-2 Working Days

+

+ Est. Waiting +

+

+ 1-2 Working Days +

-
- )} +
+ )}
@@ -740,7 +872,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
- + Rail
@@ -752,9 +887,31 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
- } label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} /> - } label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} /> - } label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} /> + } + label="Service" + value={ + booking.serviceType === "RAIL_AND_FORWARDING" + ? "Rail & Forwarding" + : "Rail Only" + } + /> + } + label="Return" + value={ + booking.equipmentReturn === "WITH_RETURN" + ? "With Return" + : "Without Return" + } + /> + } + label="Trade" + value={ + booking.tradeDirection === "IMPORT" ? "Import" : "Export" + } + />
@@ -771,14 +928,23 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {

First Mile

- +

Last Mile

- {booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"} + {booking.lastMileEnabled && booking.lastMileDeliveryAddress + ? booking.lastMileDeliveryAddress + : "Not requested"}

@@ -793,31 +959,57 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
- } label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} /> - } label="Weight (VGM)" value={`${booking.cargoTotalWeightVgm} Tons`} /> - } label="Currency" value={booking.paymentCurrency} /> + } + label="Freight Type" + value={ + booking.freightType === "BULK" ? "Bulk" : "Break Bulk" + } + /> + } + label="Weight (VGM)" + value={`${booking.cargoTotalWeightVgm} Tons`} + /> + } + label="Currency" + value={booking.paymentCurrency} + />
{booking.containers && booking.containers.length > 0 && ( <>
-

Load Details

+

+ Load Details +

- - + + {booking.containers.map((c, i) => ( - - - + + + ))} @@ -839,7 +1031,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { - +
@@ -860,15 +1055,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { {booking.freightSubtype && (
-

Cargo Description

-

"{booking.freightSubtype}"

+

+ Cargo Description +

+

+ "{booking.freightSubtype}" +

)} {booking.financialTerms && ( <>
-

Financial Terms

+

+ Financial Terms +

@@ -879,7 +1080,9 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { )} {!booking.freightSubtype && !booking.financialTerms && ( -

No additional information provided.

+

+ No additional information provided. +

)} @@ -917,17 +1120,23 @@ function RouteEndpoint({ function InfoItem({ icon, label, - value + value, }: { icon?: React.ReactNode; label: string; - value?: string | number | null + value?: string | number | null; }) { return (
- {icon &&
{icon}
} + {icon && ( +
+ {icon} +
+ )}
-

{label}

+

+ {label} +

{value ?? "—"}

@@ -946,9 +1155,12 @@ function StatusBadge({ status }: { status: string }) { return ( - {status.replace(/_/g, ' ')} + {status.replace(/_/g, " ")} ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 672b1f562..847fac2bd 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -18,7 +18,6 @@ import { trackingService } from "./tracking.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service"; import { dropdownSettingsService } from "./dropdownSettings.service"; import { authService } from "./auth.service"; -import { customersService } from "./customers.service"; import { companiesService } from "./companies.service"; import { CreateDropdownOptionDto, @@ -28,11 +27,6 @@ import { UpdateDropdownOptionDto, UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; -import { - CreateCustomerDto, - Customer, - UpdateCustomerDto, -} from "@/types/customers"; import type { CompanyInfoResponse, CreateCompanyPayload, diff --git a/tasks.md b/tasks.md deleted file mode 100644 index b829182c4..000000000 --- a/tasks.md +++ /dev/null @@ -1,5 +0,0 @@ -- In ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx the input field for the previous contract ref should be a select field with the list of contracts from the API -- the "Equipment Return" field in ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx should be a toggle like Last Mile - Delivery and should appear only if the last mile is toggled. and also add "( Door to Port)" to first mile description and vice versa to the last mile -- add Type of container- Dry container, high cubic containers , reefer containers, open top containers, flat rack, tank container, open side containers to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx -- merge the "Unpaired 20ft Container" from ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx to step 4 and fully remove the step 5 -- add Type of Shipping lines - MSC, CMA CGM, Evergreen, COSCO, Hapag-Lloyd, ONE, Yang Ming, ZIM, Messina Line, Safmarine, Wan Hai, Ethiopian Shipping Lines (ESLSE) to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
TypeQuantityVGM (Tons) + Quantity + + VGM (Tons) +
{c.type}{c.qty} Units{c.vgm}t + {c.type} + + {c.qty} Units + + {c.vgm}t +