diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 441544b39..d5989f2ac 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1974,6 +1974,11 @@ export class ContractBookingService { isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), isGovernment: contract.isGovernment, + // The clearance fee is gated on this flag in BookingPricingService, and + // createUnderContract copies it off the contract. Omitting it here priced + // the preview WITHOUT the customs line the created booking is then billed + // — the customer confirmed one total and got invoiced a larger one. + customsClearingEnabled: contract.customsClearingEnabled, shippingLineId: null, contractRouteId: route?.id ?? null, originYardId: route?.originYardId ?? null, diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts new file mode 100644 index 000000000..4152ffb30 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts @@ -0,0 +1,123 @@ +import { ContractBookingService } from './contract-booking.service'; +import type { Contract } from './entities/contract.entity'; +import type { Booking } from '../bookings/entities/booking.entity'; +import type { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; + +/** + * The price the customer confirms in the modal comes from validateShipment, + * which prices an UNSAVED twin of the booking createUnderContract will write. + * Any contract field that reaches the pricing service must be copied onto that + * twin — a field left off doesn't fail loudly, it silently drops whole charge + * lines from the quote while the created booking is still billed for them. + * + * The regression this locks: `customsClearingEnabled` was missing, so + * BookingPricingService's `if (booking.customsClearingEnabled)` gate never + * opened in the preview. Container bookings quoted rail freight alone, then + * invoiced rail + customs clearance. + */ +describe('shipment preview / created booking parity', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c1', + freightType: 'CONTAINER', + tradeDirection: 'EXPORT', + paymentCurrency: 'ETB', + serviceTypeId: 'svc1', + customsClearingEnabled: true, + equipmentReturn: 'NO_RETURN', + isHazardous: false, + isReefer: false, + isGovernment: false, + cargoScope: [], + firstMilePickupAddress: null, + lastMileDeliveryAddress: null, + ...over, + }) as Contract; + + /** + * Run validateShipment against stubbed collaborators and hand back the + * booking the pricing service was actually asked to price. + */ + const previewBookingFor = async (c: Contract): Promise => { + let priced: Booking | null = null; + + const svc = { + contractsRepository: { findByIdWithRelations: async () => c }, + bookingPricingService: { + computePriceForBooking: async (b: Booking) => { + priced = b; + return { + lineItems: [], + totalAmount: 0, + currency: 'ETB', + overweightLines: [], + hardBlocked: [], + }; + }, + }, + ruleEngineService: { capacityViolations: async () => [] }, + resolveRoute: async () => null, + resolveShipmentCurrency: () => 'ETB', + resolveCargoTypeId: () => null, + resolveShipmentHandlingFlag: () => false, + resolveShipmentEquipmentReturn: () => c.equipmentReturn, + resolveBulkTons: () => 0, + resolveBulkWeightTons: () => 0, + resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }), + handlingCounts: () => ({ + hazardousQuantity: 0, + reeferQuantity: 0, + returnQuantity: 0, + }), + max20ftPairDiffTons: async () => 2, + findContainerClashesOnTrain: async () => [], + }; + + const dto = { + containers: [ + { + containerSize: '40ft', + quantity: 2, + units: [{ vgmTons: 10 }, { vgmTons: 10 }], + }, + ], + } as unknown as CreateBookingUnderContractDto; + + await ( + ContractBookingService.prototype as unknown as { + validateShipment: ( + this: unknown, + id: string, + dto: CreateBookingUnderContractDto, + ) => Promise; + } + ).validateShipment.call(svc, 'c1', dto); + + if (!priced) throw new Error('pricing service was never called'); + return priced; + }; + + it('prices the preview with customs clearing on when the contract clears', async () => { + // Without this the clearance fee is quoted as 0 and billed in full later. + const booking = await previewBookingFor(contract()); + expect(booking.customsClearingEnabled).toBe(true); + }); + + it('leaves customs clearing off when the contract does not clear', async () => { + const booking = await previewBookingFor( + contract({ customsClearingEnabled: false }), + ); + expect(booking.customsClearingEnabled).toBe(false); + }); + + it('carries the contract mile legs so trucking is quoted too', async () => { + const booking = await previewBookingFor( + contract({ + firstMilePickupAddress: 'Modjo Dry Port', + lastMileDeliveryAddress: 'Djibouti Port', + }), + ); + expect(booking.firstMilePickupAddress).toBe('Modjo Dry Port'); + expect(booking.lastMileDeliveryAddress).toBe('Djibouti Port'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 834663170..c1b1459a3 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -39,6 +39,10 @@ import { TrainBuilderService } from './train-builder.service'; FREIGHT_PERMS.trains.update, FREIGHT_PERMS.trains.assignWagons, FREIGHT_PERMS.trains.delete, + FREIGHT_PERMS.trains.changeLocomotives, + FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.toggleActive, + FREIGHT_PERMS.trains.disband, ]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @@ -72,7 +76,7 @@ export class TrainBuilderController { } @Put(':id/locomotives') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.changeLocomotives) @ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' }) setLocomotives( @Param('id', ParseUUIDPipe) id: string, @@ -94,7 +98,7 @@ export class TrainBuilderController { } @Patch(':id/yard') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.changeYard) @ApiOperation({ summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', }) @@ -143,7 +147,7 @@ export class TrainBuilderController { } @Post(':id/deactivate') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.toggleActive) @ApiOperation({ summary: 'Deactivate the train (park it) — only allowed with no active schedule', }) @@ -152,14 +156,14 @@ export class TrainBuilderController { } @Post(':id/activate') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.toggleActive) @ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' }) activate(@Param('id', ParseUUIDPipe) id: string) { return this.trainBuilderService.activate(id); } @Delete(':id') - @FleetManage(FREIGHT_PERMS.trains.delete) + @FleetManage(FREIGHT_PERMS.trains.disband) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' }) disband(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 06511cb2b..47fde9fb4 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -707,6 +707,29 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:trains:assign_wagons", "Assign wagons to train", ), + // Granular splits of trains:update / trains:delete for the train-builder + // detail page's Actions menu — each item gets its own grant instead of + // sharing the coarse update/delete keys. + perm( + "e1c00001-0001-4000-8000-000000000006", + "edr_freight_app:trains:change_locomotives", + "Change train locomotives", + ), + perm( + "e1c00001-0001-4000-8000-000000000007", + "edr_freight_app:trains:change_yard", + "Change train yard", + ), + perm( + "e1c00001-0001-4000-8000-000000000008", + "edr_freight_app:trains:toggle_active", + "Activate or deactivate train", + ), + perm( + "e1c00001-0001-4000-8000-000000000009", + "edr_freight_app:trains:disband", + "Disband train", + ), perm( "e1d00001-0001-4000-8000-000000000001", "edr_freight_app:routes:view", @@ -1714,6 +1737,10 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:trains:update", delete: "edr_freight_app:trains:delete", assignWagons: "edr_freight_app:trains:assign_wagons", + changeLocomotives: "edr_freight_app:trains:change_locomotives", + changeYard: "edr_freight_app:trains:change_yard", + toggleActive: "edr_freight_app:trains:toggle_active", + disband: "edr_freight_app:trains:disband", }, routes: { view: "edr_freight_app:routes:view", @@ -2021,6 +2048,10 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.trains.update, FREIGHT_PERMS.trains.delete, FREIGHT_PERMS.trains.assignWagons, + FREIGHT_PERMS.trains.changeLocomotives, + FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.toggleActive, + FREIGHT_PERMS.trains.disband, FREIGHT_PERMS.routes.view, FREIGHT_PERMS.routes.create, FREIGHT_PERMS.routes.update, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 09790091d..b74c7bef9 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -188,6 +188,11 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:trains:update", delete: "edr_freight_app:trains:delete", assignWagons: "edr_freight_app:trains:assign_wagons", + /** Train-builder detail Actions menu — each item its own grant. */ + changeLocomotives: "edr_freight_app:trains:change_locomotives", + changeYard: "edr_freight_app:trains:change_yard", + toggleActive: "edr_freight_app:trains:toggle_active", + disband: "edr_freight_app:trains:disband", }, routes: { view: "edr_freight_app:routes:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx index 5291e26f1..400bfc1e5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx @@ -47,7 +47,7 @@ import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompo import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; import { useAuth } from "@/auth/useAuth"; -import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { useToast } from "@/hooks/use-toast"; import type { TrainCompositionWagon } from "@/services/trainBuilder.service"; @@ -83,9 +83,11 @@ export default function TrainBuilderDetailPage() { const [maintenanceTarget, setMaintenanceTarget] = useState(null); const { user } = useAuth(); - const canUpdate = canFleetAction(user, "trains", "update"); - const canDelete = canFleetAction(user, "trains", "delete"); const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons); + const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives); + const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard); + const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive); + const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband); const compositionQuery = useQuery( api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }), @@ -172,7 +174,7 @@ export default function TrainBuilderDetailPage() { } action={ - canUpdate || canDelete ? ( + canChangeLocomotives || canChangeYard || canToggleActive || canDisband ? ( - {canUpdate ? ( - <> - } - disabled={!composition.editable} - onClick={() => setLocoModalOpen(true)} - > - Change locomotives - - } - disabled={!composition.editable} - onClick={() => setYardModalOpen(true)} - > - Change yard - - {composition.status === "DEACTIVATED" ? ( - } - disabled={blockingLocomotives.length > 0} - onClick={() => - void withToast(async () => { - await activate.mutateAsync(composition.id); - toast({ title: `Train ${composition.code} reactivated` }); - }, "Could not reactivate train") - } - > - Reactivate train - - ) : ( - } - disabled={composition.activeSchedules.length > 0} - onClick={() => setDeactivateOpen(true)} - > - Deactivate train - - )} - + {canChangeLocomotives ? ( + } + disabled={!composition.editable} + onClick={() => setLocoModalOpen(true)} + > + Change locomotives + ) : null} - {canDelete ? ( + {canChangeYard ? ( + } + disabled={!composition.editable} + onClick={() => setYardModalOpen(true)} + > + Change yard + + ) : null} + {canToggleActive ? ( + composition.status === "DEACTIVATED" ? ( + } + disabled={blockingLocomotives.length > 0} + onClick={() => + void withToast(async () => { + await activate.mutateAsync(composition.id); + toast({ title: `Train ${composition.code} reactivated` }); + }, "Could not reactivate train") + } + > + Reactivate train + + ) : ( + } + disabled={composition.activeSchedules.length > 0} + onClick={() => setDeactivateOpen(true)} + > + Deactivate train + + ) + ) : null} + {canDisband ? ( } @@ -277,7 +281,7 @@ export default function TrainBuilderDetailPage() { . - {canUpdate ? ( + {canChangeLocomotives ? ( - - + + )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index f5be74416..9c6974641 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -87,6 +87,8 @@ export default function NewShipmentRequestPage() { contract.contractKind === "GENERAL" && (contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled); const isIntercity = contract.tradeDirection === "DOMESTIC"; + // Export shipments are invoiced in ETB only — USD is not offered. + const isExport = contract.tradeDirection === "EXPORT"; // Only the container sizes the contract was scoped for (20ft, 40ft, or both). const SIZE_ORDER = ["20ft", "40ft"]; @@ -115,7 +117,7 @@ export default function NewShipmentRequestPage() { const dto: Freight.CreateBookingRequestDto = { contractRouteId: route?.id, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, - paymentCurrency: isIntercity ? "ETB" : paymentCurrency, + paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency, notes: notes.trim() || undefined, }; @@ -246,16 +248,22 @@ export default function NewShipmentRequestPage() { {isIntercity ? "Intercity shipments are invoiced in ETB." - : "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."} + : isExport + ? "Export shipments are invoiced in ETB." + : "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."} setPaymentCurrency(v as "USD" | "ETB")} - disabled={isIntercity} - data={[ - { label: "USD", value: "USD" }, - { label: "ETB", value: "ETB" }, - ]} + disabled={isIntercity || isExport} + data={ + isExport + ? [{ label: "ETB", value: "ETB" }] + : [ + { label: "USD", value: "USD" }, + { label: "ETB", value: "ETB" }, + ] + } color="teal" radius={10} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css b/apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css new file mode 100644 index 000000000..270b91e16 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css @@ -0,0 +1,11 @@ +/* Keeps the fixed sign bar confined to the content area (right of the + navbar) instead of spanning the full viewport and drifting off-center. */ +.contract-sign-bar { + --sign-bar-left: 0px; +} + +@media (min-width: 48em) { + .contract-sign-bar { + --sign-bar-left: 260px; + } +}