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 7eb87bf86..8da28e400 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 @@ -290,6 +290,7 @@ export class ContractBookingService { isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, firstMilePickupLng: contract.firstMilePickupLng ?? null, @@ -773,6 +774,7 @@ export class ContractBookingService { cargoTypeId: this.resolveCargoTypeId(contract, dto), cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), // Completion is where the cargo — and therefore the price — is fixed, so // it is also where the billing currency is chosen. A bare instance was @@ -1253,6 +1255,7 @@ export class ContractBookingService { } probe.cargoTotalWeightVgm = this.resolveBulkTons(dto); + probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto); const cargoTypeId = this.resolveCargoTypeId(contract, dto); probe.cargoTypeId = cargoTypeId; if (cargoTypeId) { @@ -1297,11 +1300,7 @@ export class ContractBookingService { return; } - const requested = - (dto.bulkLines ?? []).reduce( - (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), - 0, - ) || this.resolveBulkTons(dto) || 0; + const requested = this.resolveBulkTons(dto); const remaining = outstanding.bulk?.outstanding ?? 0; // 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights. if (Math.abs(requested - remaining) > 0.001) { @@ -1344,8 +1343,10 @@ export class ContractBookingService { } } } else { + // PER_ITEM contracts are capped in items, so the item count is the + // consumption figure — tonnage is only wagon-sizing data. const requested = - (lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0; + Number(lines.bulk?.itemCount ?? lines.bulk?.cargoWeightTons ?? 0) || 0; const cap = capacity.find((c) => c.cap != null); if (cap && cap.remaining != null && requested > cap.remaining) { throw new BadRequestException( @@ -1373,11 +1374,7 @@ export class ContractBookingService { } } } else { - const requested = - (dto.bulkLines ?? []).reduce( - (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), - 0, - ) || this.resolveBulkTons(dto) || 0; + const requested = this.resolveBulkTons(dto); const cap = capacity.find((c) => c.cap != null); if (cap && cap.remaining != null && requested > cap.remaining) { throw new BadRequestException( @@ -1641,11 +1638,27 @@ export class ContractBookingService { private resolveBulkTons(dto: CreateBookingUnderContractDto): number { if (!dto.bulkLines?.length) return 0; return dto.bulkLines.reduce( - (sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0), + (sum, l) => sum + Number(l.itemCount ?? l.cargoWeightTons ?? 0), 0, ); } + /** + * Real tonnage of a PER_ITEM (break-bulk) booking, kept alongside the item + * count `cargoTotalWeightVgm` holds. Both are needed: the item count prices + * the booking, the tonnage sizes the wagons (`bulkItemWagonsRequired` derives + * per-item weight from tonnage ÷ items). Null for PER_TON bulk, where + * `cargoTotalWeightVgm` already IS the tonnage. + */ + private resolveBulkWeightTons( + dto: CreateBookingUnderContractDto, + ): number | null { + const lines = dto.bulkLines ?? []; + if (!lines.some((l) => Number(l.itemCount) > 0)) return null; + const tons = lines.reduce((sum, l) => sum + Number(l.cargoWeightTons ?? 0), 0); + return tons > 0 ? tons : null; + } + /** * Per-line handling counts. Each physical container carries its own hazardous * / reefer / return switch (entered next to its VGM), so the count is however @@ -1961,6 +1974,7 @@ export class ContractBookingService { originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 831e1bc1b..2ca73f10a 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -182,18 +182,18 @@ const FreightDashboardHeader = ({ )} + } + onClick={() => navigate("/dashboard/profile#signature")} + > + Signature & Stamp + } onClick={() => navigate("/dashboard/profile")} > Profile - } - onClick={() => navigate("/dashboard/profile#signature")} - > - My signature - } diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 49a144a53..6fce4ce82 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -548,18 +548,18 @@ export function AppLayout({ )} + } + onClick={() => navigate("/signature")} + > + Signature & Stamp + } onClick={() => navigate("/profile")} > Profile - } - onClick={() => navigate("/signature")} - > - My signature - } onClick={() => navigate("/settings")} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index c7206b215..50f2828f6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -992,8 +992,9 @@ function ScheduleStep({ ?.cargoTypeCode ?? undefined, totalWeightTons: tons, }; - // itemCount is referenced so the query refreshes when a PER_ITEM cargo - // amount changes (weight is the sizing input the backend uses). + // Tonnage is the sizing input the day-feasibility endpoint takes, and + // PER_ITEM cargo now captures it too — itemCount stays in the deps so the + // query still refreshes when only the item count changes. }, [ route, isContainer, @@ -1162,6 +1163,9 @@ function CargoStep({ contract: Freight.IContract; }) { const isContainer = contract.freightType === "CONTAINER"; + // Break-bulk (PER_ITEM) cargo needs BOTH the item count (which prices it) and + // the total tonnage (which sizes the wagons); PER_TON needs tonnage only. + const isPerItem = bulkUnitOfMeasure(contract) === "PER_ITEM"; // Sizes enabled by the contract scope. const sizes = useMemo( () => @@ -1193,8 +1197,7 @@ function CargoStep({ // still needs its container number. useEffect(() => { if (!remainderMode || isContainer) return; - const field = - bulkUnitOfMeasure(contract) === "PER_ITEM" ? "itemCount" : "cargoWeightTons"; + const field = isPerItem ? "itemCount" : "cargoWeightTons"; if (!form.getValues(field)) { form.setValue(field, String(remainderLines[0].remaining), { shouldValidate: false, @@ -1478,6 +1481,27 @@ function CargoStep({ {remainderNotice} + {isPerItem && ( + ( + + )} + /> + )} )} /> - ( - - )} - /> {contract.isHazardous && ( { expect(t.total).toBe(200 + 60); }); }); + +// PER_ITEM (break-bulk) cargo captures BOTH an item count and a total tonnage: +// the item count prices the booking, the tonnage sizes the wagons. Before this, +// the estimate took `cargoWeightTons || itemCount` and so billed a per_item rate +// against the tonnage as soon as both fields were filled. +describe("computeShipmentTotal — PER_ITEM bulk", () => { + const perItemContract = contract({ + freightType: "BULK", + isHazardous: false, + isReefer: false, + equipmentReturn: "NO_RETURN", + pricingBreakdown: { + currency: "ETB", + lineItems: [ + { label: "Break-bulk freight", unit: "per_item", unitPrice: 50 }, + { + label: "Customs clearance", + unit: "per_item", + unitPrice: 5, + isClearance: true, + }, + ], + }, + } as unknown as Partial); + + it("bills a per_item rate on the item count, not the tonnage", () => { + const t = computeShipmentTotal( + perItemContract, + values({ containers: [], itemCount: "400", cargoWeightTons: "800" }), + ); + expect(line(t, "Break-bulk freight")).toMatchObject({ + quantity: 400, + amount: 20000, + }); + expect(line(t, "Customs clearance")).toMatchObject({ + quantity: 400, + amount: 2000, + }); + expect(t.total).toBe(22000); + }); + + it("still bills a per_ton rate on the tonnage", () => { + const perTon = contract({ + freightType: "BULK", + isHazardous: false, + isReefer: false, + equipmentReturn: "NO_RETURN", + pricingBreakdown: { + currency: "ETB", + lineItems: [{ label: "Bulk freight", unit: "per_ton", unitPrice: 10 }], + }, + } as unknown as Partial); + const t = computeShipmentTotal( + perTon, + values({ containers: [], cargoWeightTons: "800", itemCount: "" }), + ); + expect(line(t, "Bulk freight")).toMatchObject({ quantity: 800, amount: 8000 }); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index 7fd35c174..cbe9ae083 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -15,6 +15,18 @@ export interface ShipmentTotal { total: number; } +/** + * Quantity a bulk rate bills, in ITS OWN unit. PER_ITEM cargo carries both + * figures — the item count prices the booking, the tonnage sizes the wagons — + * so a per_item rate must bill items even though tonnage is also filled in. + * PER_TON cargo has no item count and falls back the other way for legacy rows. + */ +function bulkQtyForUnit(values: ShipmentFormValues, unit: string): number { + return unit === "per_item" + ? Number(values.itemCount || 0) + : Number(values.cargoWeightTons || values.itemCount || 0); +} + /** * Compute the booking total client-side from the contract's frozen unit rates × * the quantities the customer enters (doc §9.2). This is an estimate shown in @@ -115,7 +127,6 @@ export function computeShipmentTotal( } } } else { - const qty = Number(values.cargoWeightTons || values.itemCount || 0); const rate = rateFor( (i) => @@ -123,6 +134,7 @@ export function computeShipmentTotal( !i.isClearance && !i.conditionalOn, ) ?? items[0]; + const qty = rate ? bulkQtyForUnit(values, rate.unit) : 0; if (rate && qty > 0) { lines.push({ label: rate.label, @@ -166,14 +178,14 @@ export function computeShipmentTotal( // real pricing. const lashing = items.find((i) => i.conditionalOn === "has_lashing"); if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) { - const tons = Number(values.cargoWeightTons || values.itemCount || 0); - if (tons > 0) { + const qty = bulkQtyForUnit(values, lashing.unit); + if (qty > 0) { lines.push({ label: lashing.label, unitPrice: lashing.unitPrice, unit: lashing.unit, - quantity: tons, - amount: lashing.unitPrice * tons, + quantity: qty, + amount: lashing.unitPrice * qty, }); } } @@ -193,7 +205,7 @@ export function computeShipmentTotal( ? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5)) : boxes; } else if (cl.unit === "per_ton" || cl.unit === "per_item") { - qty = Number(values.cargoWeightTons || values.itemCount || 0); + qty = bulkQtyForUnit(values, cl.unit); } else if (cl.unit === "flat") { qty = 1; }