diff --git a/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts b/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts new file mode 100644 index 000000000..ce05a7ce1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Livestock is billed and counted per head, not per ton — line it up with the + * other break-bulk cargo types (Machinery, Truck, Automobile) so bulk + * storage/demurrage fees charge per item instead of per ton for it. + */ +export class LivestockPerItem2900000000000 implements MigrationInterface { + name = "LivestockPerItem2900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.cargo_types + SET unit_of_measure = 'PER_ITEM' + WHERE code = 'LIVESTOCK' + AND unit_of_measure IS DISTINCT FROM 'PER_ITEM' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.cargo_types + SET unit_of_measure = 'PER_TON' + WHERE code = 'LIVESTOCK' + AND unit_of_measure IS DISTINCT FROM 'PER_TON' + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts index c0e0b7c5f..20ac4859a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -67,6 +67,21 @@ export class FacilityHandlingService { inventoryId = inv?.id ?? null; } + // The handed-over weight: the booking's declared VGM, else what its + // containers actually carry. A GRN without a weight is not a receipt. + let weightTons = Number(booking.cargoTotalWeightVgm) || null; + if (!weightTons) { + const [sum]: Array<{ tons: string | null }> = await manager.query( + `SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, + [booking.id], + ); + weightTons = Number(sum?.tons) || null; + } + const repo = manager.getRepository(FacilityHandlingEvent); await repo.save( repo.create({ @@ -75,7 +90,7 @@ export class FacilityHandlingService { trainScheduleId: input.trainScheduleId ?? null, eventType, grnNumber, - weightTons: Number(booking.cargoTotalWeightVgm) || null, + weightTons, inventoryId, performedBy: input.performedBy ?? null, occurredAt, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts new file mode 100644 index 000000000..aa4d5e7b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts @@ -0,0 +1,197 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; +import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; + +// Bulk storage/demurrage used to bill a flat rate per day regardless of cargo +// quantity. It now scales by the cargo type's own unit of measure — tons for +// PER_TON cargo, item count for PER_ITEM cargo (Machinery, Truck, Automobile, +// Livestock…) — read from THIS inventory row, not the whole booking's total. +describe('WarehouseFeeService bulk quantity billing', () => { + const makeService = () => + // compute() only touches its own arguments plus this.convertAmount, which + // short-circuits when rule.currency === billingCurrency — none of the + // constructor deps are exercised. + new WarehouseFeeService({} as any, {} as any, {} as any, {} as any); + + const rule = (overrides: Partial = {}): WarehouseFeeRule => + ({ + id: 'rule-1', + name: 'Bulk storage', + ruleType: 'STORAGE_FEE', + freeDays: 0, + ratePerDay: 10, + currency: 'USD', + tiers: [], + ...overrides, + }) as WarehouseFeeRule; + + const baseItem = (overrides: Record = {}) => ({ + arrivedAt: new Date('2026-01-01T00:00:00Z'), + gateClearedAt: null, + releaseDate: null, + freightType: 'BULK', + tradeDirection: 'IMPORT', + cargoTypeCode: 'WHEAT', + containerTypeCode: null, + vehicleType: null, + inventoryQuantity: 3, + inventoryWeight: 25, + bookingContainerCount: 0, + cargoUnitOfMeasure: null, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + ...overrides, + }); + + // 5 elapsed days, 0 free days -> 5 chargeable days throughout. + const now = new Date('2026-01-06T00:00:00Z'); + + it('bills PER_TON bulk cargo by this row\'s weight, not a flat day rate', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 25 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.containerCount).toBe(25); + expect(preview.billableUnits).toBe(5 * 25); + expect(preview.amount).toBe(5 * 25 * 10); + }); + + it('bills PER_ITEM bulk cargo (Machinery/Truck/Automobile/Livestock) by unit count', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 3, cargoTypeCode: 'MACHINERY' }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('item'); + expect(preview.containerCount).toBe(3); + expect(preview.billableUnits).toBe(5 * 3); + expect(preview.amount).toBe(5 * 3 * 10); + }); + + it('defaults to PER_TON when the cargo type has no unit of measure set', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: null, inventoryWeight: 12 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.containerCount).toBe(12); + }); + + it('charges nothing yet when the row has not been weighed/counted (0 is legitimate, not floored to 1)', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 0 }), + now, + 'USD', + ); + expect(preview.containerCount).toBe(0); + expect(preview.billableUnits).toBe(0); + expect(preview.amount).toBe(0); + }); + + it('leaves CONTAINER freight billing untouched by the new bulk fields', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DEMURRAGE_FEE', + rule({ ruleType: 'DEMURRAGE_FEE' }), + baseItem({ + freightType: 'CONTAINER', + bookingContainerCount: 4, + cargoUnitOfMeasure: 'PER_ITEM', // must be ignored for container freight + inventoryWeight: 999, + }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('container'); + expect(preview.containerCount).toBe(4); + expect(preview.billableUnits).toBe(5 * 4); + }); + + // Double handling is a flat one-time charge, but previewForInventory() calls + // it once per warehouse_inventory ROW. Before this fix it read the whole + // booking's total on every row, so a booking split across N rows was billed + // N times against its full quantity. Reading each row's own weight/count + // fixes that: summing the rows now reproduces the booking total exactly once. + describe('double handling (row-level, not booking-wide)', () => { + const doubleHandlingRule = (basis: 'PER_CONTAINER' | 'PER_TON' | 'PER_ITEM') => + rule({ ruleType: 'DOUBLE_HANDLING_FEE', basis, ratePerDay: 20 }); + + it('bills PER_TON by this row\'s own weight', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_TON'), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.billableUnits).toBe(10); + expect(preview.amount).toBe(10 * 20); + }); + + it('bills PER_ITEM by this row\'s own unit count', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_ITEM'), + baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 2, cargoTypeCode: 'TRUCK' }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('item'); + expect(preview.billableUnits).toBe(2); + expect(preview.amount).toBe(2 * 20); + }); + + it('two rows of one booking sum to the booking total exactly once (no N-times overcount)', async () => { + const service = makeService(); + const ruleDef = doubleHandlingRule('PER_TON'); + const rowA = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + ruleDef, + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 6 }), + now, + 'USD', + ); + const rowB = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + ruleDef, + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 4 }), + now, + 'USD', + ); + // Booking total is 10 tons across the two rows — billed once in total, + // not 10 tons charged against EACH row (which the old booking-wide read did). + expect(rowA.amount + rowB.amount).toBe(10 * 20); + }); + + it('no charge for export/domestic regardless of basis', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_TON'), + baseItem({ tradeDirection: 'EXPORT', cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), + now, + 'USD', + ); + expect(preview.amount).toBe(0); + }); + }); +}); 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 a60261c1e..ec1b90789 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 @@ -20,9 +20,11 @@ interface ItemAttributes { /** Vehicle type of the truck (truck detention scoping); null otherwise. */ vehicleType: string | null; inventoryQuantity: number; + /** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */ + inventoryWeight: number; bookingContainerCount: number; - /** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ - cargoQuantity: 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; facilityId: string | null; warehouseId: string | null; yardId: string | null; @@ -60,7 +62,7 @@ export interface AccrualDashboardRow { export interface FeePreview { ruleType: FeeRuleType; - /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ + /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_ITEM); null otherwise. */ basis: FeeRuleBasis | null; ruleId: string | null; ruleName: string | null; @@ -75,6 +77,8 @@ export interface FeePreview { elapsedDays: number; chargeableDays: number; containerCount: number; + /** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */ + unitLabel: string; billableUnits: number; amount: number; tiers: Array<{ @@ -242,6 +246,7 @@ export class WarehouseFeeService { inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", inv.quantity AS "inventoryQuantity", + inv.weight AS "inventoryWeight", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", @@ -251,7 +256,7 @@ export class WarehouseFeeService { COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", COALESCE(container_lines.container_count, 0) AS "bookingContainerCount", - COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity" + COALESCE(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -470,6 +475,22 @@ export class WarehouseFeeService { }; } + /** + * Bulk's own billing quantity for THIS inventory row — weight (tons) for + * PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile, + * Livestock…). Shared by every cargo-scoped fee type (storage, demurrage, + * double handling) so a booking split across several rows is never billed + * more than once against its full total. 0 is a legitimate charge (nothing + * weighed/counted yet), so no forced floor. + */ + private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } { + const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase(); + if (cargoUnit === 'PER_ITEM') { + return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' }; + } + return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' }; + } + private async compute( ruleType: FeeRuleType, rule: WarehouseFeeRule | null, @@ -490,9 +511,11 @@ export class WarehouseFeeService { const targetCurrency = this.normalizeCurrency(billingCurrency); const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); + const bulk = this.resolveBulkQuantity(item); const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) - : 1; + : bulk.quantity; + const unitLabel = isContainer ? 'container' : bulk.unitLabel; const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) @@ -533,6 +556,7 @@ export class WarehouseFeeService { elapsedDays, chargeableDays, containerCount, + unitLabel, billableUnits, amount, tiers: hasTiers ? convertedTiers : [], @@ -560,12 +584,15 @@ export class WarehouseFeeService { const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) : 1; - // PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total, - // which is stored in the cargo's own unit of measure. - const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0); + // PER_TON (tonnes) and PER_ITEM (piece count) both read THIS row's own + // weight/count — never the whole booking's total. previewForInventory() + // 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. const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; - const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity; + const quantity = !isImport ? 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; const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0; @@ -586,6 +613,7 @@ export class WarehouseFeeService { elapsedDays: 0, chargeableDays: 0, containerCount, + unitLabel, billableUnits: quantity, amount, tiers: [], @@ -771,6 +799,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: null, ruleName: null, freeDays: 0, @@ -828,8 +857,9 @@ export class WarehouseFeeService { containerTypeCode: null, vehicleType: g.vehicleType ?? null, inventoryQuantity: 1, + inventoryWeight: 0, bookingContainerCount: 1, - cargoQuantity: 0, + cargoUnitOfMeasure: null, facilityId: null, warehouseId: null, yardId: null, @@ -856,6 +886,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: single?.ruleId ?? null, ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, freeDays: 0, @@ -927,6 +958,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays: 0, 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 582face93..6bff0752f 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 @@ -1144,7 +1144,17 @@ export class WarehouseInventoryService { tradeDirection: string | null; cargoTypeCode: string | null; }[] = await this.dataSource.query( - `SELECT b.id, b.cargo_total_weight_vgm AS weight, + `SELECT b.id, + -- Received weight must land on the inventory row: a booking with no + -- declared VGM still has per-container VGM to record. + COALESCE( + NULLIF(b.cargo_total_weight_vgm, 0), + (SELECT SUM(bcu.vgm_tons) + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) + ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", cgt.code AS "cargoTypeCode" FROM freight.bookings b @@ -2140,7 +2150,17 @@ export class WarehouseInventoryService { // at an intermediate yard was already unloaded there by the checkpoint // auto-unload; without this filter it would be mis-located into the final // yard's inventory too. - `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight, + `SELECT b.id, b.status, + -- Same fallback as autoUnloadArrived: never land a 0 t receipt when + -- the booking's containers carry a VGM. + COALESCE( + NULLIF(b.cargo_total_weight_vgm, 0), + (SELECT SUM(bcu.vgm_tons) + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) + ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", cgt.code AS "cargoTypeCode" FROM freight.train_schedule_bookings tsb @@ -2203,6 +2223,11 @@ export class WarehouseInventoryService { zoneId: unloadLocation.zoneId, } : {}), + // Record the received weight on a row that never carried one — the + // GRN prints this, and an existing non-zero weight is left alone. + ...(Number(existing.weight) > 0 || !(Number(booking.weight) > 0) + ? {} + : { weight: Number(booking.weight) }), status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, @@ -2216,6 +2241,22 @@ export class WarehouseInventoryService { description: 'Unloaded from arrived import train', performedBy, }); + // Capacity follows the recorded weight: deliver() decrements by the + // item's weight, so a weight written here must be counted here too. + const addedWeight = Number(booking.weight) - Number(existing.weight ?? 0); + if (addedWeight > 0) { + await this.applyCapacityDelta( + this.dataSource.manager, + { + warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId, + yardId: unloadLocation?.yardId ?? existing.yardId, + zoneId: unloadLocation?.zoneId ?? existing.zoneId, + }, + addedWeight, + 0, + 0, + ); + } result.unloadedCount += 1; result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' }); continue; @@ -2253,6 +2294,11 @@ export class WarehouseInventoryService { description: 'Unloaded from arrived import train', performedBy, }); + // New goods physically in the warehouse — count them, or deliver() would + // later free capacity that was never taken. + if (Number(saved.weight) > 0) { + await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0); + } result.unloadedCount += 1; result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' }); } catch (error) { @@ -3944,7 +3990,10 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt", inv.quantity, - inv.weight, + -- An unweighed item still reports the cargo weight it holds: fall + -- back to the item's container VGM, then the booking's declared + -- weight, so a GRN never prints "0 t" for goods that are present. + COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, inv.volume, inv.status, inv.notes, @@ -3992,6 +4041,16 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id + AND bcu.deleted_at IS NULL + AND (container.container_number IS NULL + OR bcu.container_number = container.container_number) + ) item_vgm ON true LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) WHERE inv.id = $1 AND inv.deleted_at IS NULL diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 5eeee7d13..d3c8e1897 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -37,6 +37,14 @@ function fmtDate(iso: string | null) { return new Date(iso).toLocaleDateString(); } +const UNIT_LABEL_PLURAL: Record = { + container: 'Containers', + truck: 'Trucks', + ton: 'Tons', + item: 'Items', +}; +const unitLabelPlural = (unitLabel?: string) => UNIT_LABEL_PLURAL[unitLabel ?? 'container'] ?? 'Containers'; + const money = (amount: number, currency: string) => `${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; @@ -72,8 +80,11 @@ function FeeCard({ fee }: { fee: FeePreview }) { - - + + {(fee.tiers ?? []).map((tier) => ( { return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : []; }, [assignBooking, activeRecord]); + // Container number → size ("20ft"/"40ft"), driving the per-truck cap: a 40ft + // fills the truck alone; two 20ft may share (no size mixing). + const sizeByNumber = useMemo(() => { + const map = new Map(); + const lines = assignBooking?.bookingContainers?.length + ? assignBooking.bookingContainers + : activeRecord?.booking?.bookingContainers ?? []; + for (const line of lines) { + // The two payload shapes differ: the list record carries `containerSize`, + // the booking detail exposes the size on its container type. + const c = line as { + containerSize?: string | null; + containerNumber?: string | null; + containerType?: { code?: string; label?: string; sizeFt?: number }; + units?: Array<{ containerNumber?: string | null }>; + }; + const size = String( + c.containerSize ?? c.containerType?.sizeFt ?? c.containerType?.code ?? c.containerType?.label ?? "", + ); + for (const u of c.units ?? []) { + if (u.containerNumber) map.set(u.containerNumber, size); + } + if (c.containerNumber) map.set(c.containerNumber, size); + } + return map; + }, [assignBooking, activeRecord]); + const is40 = (n: string) => (sizeByNumber.get(n) ?? "").includes("40"); + + // Trucks that already arrived/left keep their load locked — the API rejects + // changing or removing them; the modal greys those rows out. + const lockedVehicles = useMemo(() => { + const map = new Map(); + for (const a of activeRecord?.vehicleAssignments ?? []) { + if (a.departedAt) map.set(a.vehicleId, "left the warehouse"); + else if (a.arrivedAt) map.set(a.vehicleId, "arrived at the warehouse"); + } + return map; + }, [activeRecord]); + const pickupReadyByBooking = useMemo(() => { const map = new Map(); for (const row of pickupReadyRows) { @@ -1101,6 +1140,22 @@ const LastMilePage = () => { if (!targetIds.length) return; + // A 40ft container fills its truck — backstop for pre-filled reassignment + // rows the MultiSelect guard never saw. + const overloaded = vehicles.filter( + (v) => v.containerNumbers.length > 1 && v.containerNumbers.some(is40), + ); + if (overloaded.length) { + toast({ + title: "40ft fills the truck", + description: `${overloaded + .map((v) => vehicleLabelFor(v.vehicleId)) + .join("; ")} — a 40ft container travels alone.`, + variant: "destructive", + }); + return; + } + // Backstop for rows the Select guard never saw (pre-filled reassignments). const unpriced = vehicles .map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) })) @@ -1385,7 +1440,21 @@ const LastMilePage = () => { status === "PAYMENT_PENDING" || (status === "READY_TO_TRANSIT" && assigned) || (status === "IN_TRANSIT" && hasDistance); - const canAssignStep = !assigned && status !== "DELIVERED"; + // Assign stays active until the whole load has trucks: container + // bookings until every container is on a truck; bulk until the + // tonnage is drawn down (trucks depart one by one). Already-departed + // trucks keep their rows locked in the modal. + const totalContainers = containerCount(row.original); + const assignedContainers = (row.original.vehicleAssignments ?? []).reduce( + (s, a) => s + (a.containers?.length ?? (a.containerNumber ? 1 : 0)), + 0, + ); + const containersRemain = totalContainers > 0 && assignedContainers < totalContainers; + const bulkCargo = totalContainers === 0; + const canAssignStep = + status !== "DELIVERED" && + !row.original.invoice && + (!assigned || containersRemain || (bulkCargo && status !== "IN_TRANSIT")); const canDistance = status === "IN_TRANSIT"; // Truck arrival/leaving are independent — each driven by its own // warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED. @@ -1822,16 +1891,22 @@ const LastMilePage = () => { ); } - const ok = picked === needed; + const coveredContainers = vehicleRows.reduce( + (s, r) => s + (r.vehicleId ? r.containerNumbers.length : 0), + 0, + ); + const ok = picked === needed && coveredContainers === containers; return ( - One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers. - {picked > 0 && !ok && - ` You've selected ${picked} — ${picked < needed ? "add more" : "that's more than needed"}.`} + One 40ft container fills a truck; two 20ft share one (no size mixing). + {containers - coveredContainers > 0 && + ` ${containers - coveredContainers} container${containers - coveredContainers === 1 ? "" : "s"} still unassigned — keep adding trucks.`} + {picked > 0 && picked !== needed && + ` You've selected ${picked} vehicle${picked === 1 ? "" : "s"} — ${picked < needed ? "add more" : "that's more than needed"}.`} ); })()} @@ -1851,7 +1926,11 @@ const LastMilePage = () => { )} - {vehicleRows.map((row, i) => ( + {vehicleRows.map((row, i) => { + const lockReason = row.vehicleId ? lockedVehicles.get(row.vehicleId) : undefined; + const rowLocked = Boolean(lockReason); + const rowHas40 = row.containerNumbers.some(is40); + return (