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;
}