diff --git a/apps/edr-freight-api/src/migrations/2850000000000-AddBookingDoubleHandling.ts b/apps/edr-freight-api/src/migrations/2850000000000-AddBookingDoubleHandling.ts new file mode 100644 index 000000000..be98729e7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2850000000000-AddBookingDoubleHandling.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Double handling becomes an explicit per-booking decision instead of an + * implicit "every import" charge. Warehouse staff record Yes/No after + * unloading (whether the goods actually had to be re-handled); the + * DOUBLE_HANDLING_FEE rule only bills when the answer is Yes. + * + * NULL = not decided yet → no charge, and the UI shows "not set" so the + * operator is prompted. Existing rows stay NULL deliberately: back-billing a + * fee nobody confirmed would be wrong. + */ +export class AddBookingDoubleHandling2850000000000 implements MigrationInterface { + name = 'AddBookingDoubleHandling2850000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling boolean;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_at timestamptz;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_by varchar(160);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_by;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 821b9c9f6..e47ed378c 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -302,6 +302,20 @@ export class Booking extends BaseEntity { @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) customerTruckArrivedAt?: Date | null; + /** + * Did the goods need re-handling in the warehouse? Recorded by warehouse + * staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule; + * null = not yet decided (no charge). + */ + @Column({ name: 'double_handling', type: 'boolean', nullable: true }) + doubleHandling?: boolean | null; + + @Column({ name: 'double_handling_set_at', type: 'timestamptz', nullable: true }) + doubleHandlingSetAt?: Date | null; + + @Column({ name: 'double_handling_set_by', type: 'varchar', length: 160, nullable: true }) + doubleHandlingSetBy?: string | null; + @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) customsClearingEnabled!: boolean; diff --git a/apps/edr-freight-api/src/modules/warehouses/double-handling-gate.spec.ts b/apps/edr-freight-api/src/modules/warehouses/double-handling-gate.spec.ts new file mode 100644 index 000000000..836a3bc4c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/double-handling-gate.spec.ts @@ -0,0 +1,61 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; + +/** + * Double handling bills ONLY when warehouse staff answered Yes after + * unloading. Undecided (null) or No must produce a zero charge even when a + * matching DOUBLE_HANDLING_FEE rule exists. + */ +type Item = Parameters extends unknown + ? Record + : never; + +const svc = Object.create(WarehouseFeeService.prototype) as { + computeDoubleHandling: ( + rule: Record | null, + item: Item, + now: Date, + billingCurrency: string, + ) => Promise<{ amount: number; billableUnits: number }>; + normalizeCurrency: (c?: string | null) => string; + convertAmount: (a: number, from: string, to: string) => Promise; + resolveBulkQuantity: (item: Item) => { quantity: number; unitLabel: string }; +}; +// No exchange service on a bare prototype — bill in the rule's own currency. +svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD'); +svc.convertAmount = async (a) => a; + +const rule = { basis: 'PER_CONTAINER', ratePerDay: 100, currency: 'USD', id: 'r1', name: 'DH' }; +const item = (doubleHandling: boolean | null) => ({ + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + inventoryQuantity: 2, + bookingContainerCount: 3, + inventoryWeight: 10, + cargoUnitOfMeasure: 'PER_TON', + doubleHandling, +}) as unknown as Item; + +describe('double handling gate', () => { + it('bills rate x containers when the booking is flagged Yes', async () => { + const out = await svc.computeDoubleHandling(rule, item(true), new Date(), 'USD'); + expect(out.billableUnits).toBe(3); + expect(out.amount).toBe(300); + }); + + it('charges nothing when the answer is No', async () => { + const out = await svc.computeDoubleHandling(rule, item(false), new Date(), 'USD'); + expect(out.billableUnits).toBe(0); + expect(out.amount).toBe(0); + }); + + it('charges nothing while the answer is undecided', async () => { + const out = await svc.computeDoubleHandling(rule, item(null), new Date(), 'USD'); + expect(out.amount).toBe(0); + }); + + it('charges nothing for export even when flagged Yes', async () => { + const exportItem = { ...(item(true) as Record), tradeDirection: 'EXPORT' } as Item; + const out = await svc.computeDoubleHandling(rule, exportItem, new Date(), 'USD'); + expect(out.amount).toBe(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/double-handling.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/double-handling.dto.ts new file mode 100644 index 000000000..21e54589f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/double-handling.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; + +/** + * Warehouse staff's post-unloading answer: did these goods have to be + * re-handled? Only `true` makes the DOUBLE_HANDLING_FEE rule bill the booking. + */ +export class SetDoubleHandlingDto { + @ApiProperty({ + description: 'Yes (true) applies the double-handling fee rule; No (false) does not.', + }) + @IsBoolean() + doubleHandling!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index ec1b90789..0d8c6549f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -25,6 +25,8 @@ interface ItemAttributes { bookingContainerCount: number; /** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */ cargoUnitOfMeasure: string | null; + /** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */ + doubleHandling: boolean | null; facilityId: string | null; warehouseId: string | null; yardId: string | null; @@ -253,6 +255,7 @@ export class WarehouseFeeService { w.facility_id AS "facilityId", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + b.double_handling AS "doubleHandling", COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", COALESCE(container_lines.container_count, 0) AS "bookingContainerCount", @@ -589,9 +592,13 @@ export class WarehouseFeeService { // computes double handling once per inventory row, so a booking-wide total // would double- (or triple-) bill a booking split across several rows. const bulk = this.resolveBulkQuantity(item); - // Double handling applies to IMPORT only — no charge for export/domestic. + // Double handling applies to IMPORT only — no charge for export/domestic — + // AND only when warehouse staff recorded that the goods were actually + // re-handled (booking flag = Yes after unloading). Undecided (null) or No + // means no charge, so the rule can exist without billing every import. const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; - const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity; + const applies = isImport && item.doubleHandling === true; + const quantity = !applies ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity; const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel; const sourceAmount = Math.round(rate * quantity * 100) / 100; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; @@ -860,6 +867,8 @@ export class WarehouseFeeService { inventoryWeight: 0, bookingContainerCount: 1, cargoUnitOfMeasure: null, + // Irrelevant to detention (truck-time based, never double handling). + doubleHandling: null, facilityId: null, warehouseId: null, yardId: null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index b3868b465..b78d6f6f9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -17,6 +17,7 @@ import { MoveInventoryDto } from './dto/move-inventory.dto'; import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { ApproveDeliveryDto } from './dto/approve-delivery.dto'; +import { SetDoubleHandlingDto } from './dto/double-handling.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; @@ -552,6 +553,23 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Patch('bookings/:bookingId/double-handling') + @BookingStaff([FREIGHT_PERMS.warehouseInventory.unload, FREIGHT_PERMS.warehouseInventory.inspect]) + @ApiOperation({ + summary: 'Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)', + }) + setDoubleHandling( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: SetDoubleHandlingDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.setDoubleHandling( + bookingId, + dto.doubleHandling, + actorLabel(user), + ); + } + @Get('bookings/:bookingId/container-items') @StaffReference() @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 6bff0752f..275cf08dc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -382,6 +382,8 @@ export interface ImportUnloadedRow { customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; hasAssignedTruck: boolean; + /** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */ + doubleHandling: boolean | null; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; @@ -1991,6 +1993,7 @@ export class WarehouseInventoryService { WHERE lm.booking_id = b.id AND lm.vehicle_id IS NOT NULL AND lm.deleted_at IS NULL)) AS "hasAssignedTruck", + b.double_handling AS "doubleHandling", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", @@ -4438,6 +4441,81 @@ export class WarehouseInventoryService { }); } + /** + * Record whether a booking's goods needed double handling. Answered by + * warehouse staff once the goods are unloaded — only Yes bills the + * DOUBLE_HANDLING_FEE rule (see WarehouseFeeService.computeDoubleHandling). + * Locked once the fee has been invoiced, so a billed charge can't be + * retro-cancelled from the operations screen. + */ + async setDoubleHandling( + bookingId: string, + doubleHandling: boolean, + performedBy?: string, + ): Promise<{ bookingId: string; doubleHandling: boolean; setAt: string }> { + const [booking]: Array<{ id: string; tradeDirection: string | null; reference: string | null }> = + await this.dataSource.query( + `SELECT id, trade_direction AS "tradeDirection", reference + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') { + throw new BadRequestException('Double handling applies to import bookings only'); + } + + // Warehouse fees are billed per inventory row (invoices.source = 'warehouse', + // source_id = the inventory id), with the fee type on the line's charge_type. + const [invoiced]: Array<{ one: number }> = await this.dataSource.query( + `SELECT 1 AS one + FROM freight.invoices i + JOIN freight.invoice_lines il ON il.invoice_id = i.id AND il.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.id::text = i.source_id AND inv.deleted_at IS NULL + WHERE inv.booking_id = $1 + AND i.source = 'warehouse' + AND i.deleted_at IS NULL + AND i.status <> 'CANCELLED' + AND il.charge_type = 'DOUBLE_HANDLING' + LIMIT 1`, + [bookingId], + ); + if (invoiced) { + throw new BadRequestException( + 'Double handling has already been invoiced for this booking — cancel the invoice to change it', + ); + } + + const setAt = new Date(); + await this.dataSource.query( + `UPDATE freight.bookings + SET double_handling = $2, + double_handling_set_at = $3, + double_handling_set_by = $4, + updated_at = NOW() + WHERE id = $1`, + [bookingId, doubleHandling, setAt, performedBy ?? null], + ); + + // Audit on the booking's inventory rows so it shows in warehouse history. + const items: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + for (const item of items) { + await this.activityLog.record({ + activityType: 'INVENTORY_STORED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: `Double handling set to ${doubleHandling ? 'YES — fee rule applies' : 'NO'}`, + performedBy, + }); + } + + return { bookingId, doubleHandling, setAt: setAt.toISOString() }; + } + /** Resolve the primary warehouse-inventory item for a booking (most recent). */ private async primaryInventoryIdForBooking(bookingId: string): Promise { const [inv]: Array<{ id: string }> = await this.dataSource.query( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 41e5fcc81..54e60d8b2 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -22,6 +22,7 @@ import { } from '@mantine/core'; import { ArrowRightLeft, + Check, ChevronDown, ChevronRight, ClipboardCheck, @@ -29,6 +30,7 @@ import { FileText, History, Info, + Layers, MapPin, MoreHorizontal, PackageCheck, @@ -2721,6 +2723,40 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { )} setInspectId(r.id)}>Inspect / report + {/* Double handling is decided once the goods are off + the wagon (every row here is unloaded) — Yes is + what makes the fee rule bill this booking. */} + + + Double handling —{' '} + {r.doubleHandling == null ? 'not set' : r.doubleHandling ? 'Yes' : 'No'} + + : + } + disabled={!r.bookingId || r.doubleHandling === true} + onClick={() => + runRowAction(r, 'Double handling: Yes — fee rule applies', () => + warehouseService.setDoubleHandling(r.bookingId as string, true), + ) + } + > + Yes — apply fee + + : + } + disabled={!r.bookingId || r.doubleHandling === false} + onClick={() => + runRowAction(r, 'Double handling: No', () => + warehouseService.setDoubleHandling(r.bookingId as string, false), + ) + } + > + No + } onClick={() => setFeeItem(toInventoryItem(r))}> Storage / fee preview diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 0d1d01c3d..f32195b69 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -334,6 +334,13 @@ export const warehouseService = { deliver: (id: string, payload: DeliverInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload), + /** Post-unloading Yes/No — Yes makes the double-handling fee rule bill this booking. */ + setDoubleHandling: (bookingId: string, doubleHandling: boolean) => + apiClient.patch<{ bookingId: string; doubleHandling: boolean; setAt: string }>( + `/warehouse-inventory/bookings/${bookingId}/double-handling`, + { doubleHandling }, + ), + // ── Receive (Import/Export bulk) ───────────────────────────────────────── eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)), diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 04cd713e9..c38d9b40d 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -603,6 +603,8 @@ export interface ImportUnloadedItem { customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; hasAssignedTruck: boolean; + /** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */ + doubleHandling: boolean | null; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null;