Merge pull request #1068 from Tria-plc/freight_feature/usermanagement

Add a minimal payment mock server for end-to-end testing
This commit is contained in:
marshal
2026-08-01 23:41:23 +03:00
committed by GitHub
7 changed files with 163 additions and 53 deletions

View File

@@ -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 }) =>

View File

@@ -182,18 +182,18 @@ const FreightDashboardHeader = ({
)}
</Box>
<Divider />
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/dashboard/profile#signature")}
>
Signature &amp; Stamp
</Menu.Item>
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/dashboard/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/dashboard/profile#signature")}
>
My signature
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<LogOut size={15} />}

View File

@@ -548,18 +548,18 @@ export function AppLayout({
</>
)}
<Divider />
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/signature")}
>
Signature &amp; Stamp
</Menu.Item>
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/signature")}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<Settings size={15} />}
onClick={() => navigate("/settings")}

View File

@@ -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({
<Stack gap={14}>
{remainderNotice}
<ContractCapacityNotice contractId={contract.id} isContainer={false} />
{isPerItem && (
<Controller
name="itemCount"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Number of items *"
description="This contract is priced per item — the item count sets the price."
placeholder="e.g. 500"
min={0}
step={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
<Controller
name="cargoWeightTons"
control={form.control}
@@ -1486,7 +1510,12 @@ function CargoStep({
{...field}
type="number"
onKeyDown={blockNegative}
label="Quantity (tons)"
label="Total weight (tons) *"
description={
isPerItem
? "Combined weight of all the items — used to work out how many wagons the shipment needs."
: undefined
}
placeholder="e.g. 1200"
min={0}
step={0.01}
@@ -1496,24 +1525,6 @@ function CargoStep({
/>
)}
/>
<Controller
name="itemCount"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Item count (if applicable)"
placeholder="e.g. 500"
min={0}
step={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
{contract.isHazardous && (
<Controller
name="bulkHazardousQuantity"

View File

@@ -221,6 +221,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
// PER_ITEM cargo needs BOTH figures: the item count prices the booking,
// the total weight sizes the wagons (per-item weight = tons ÷ items).
// PER_TON needs only the tonnage, which `bulkCap` already covers.
if (isPerItem) {
const tons = Number(data.cargoWeightTons || 0);
if (Number.isNaN(tons) || tons <= 0) {
refineCtx.addIssue({
code: "custom",
path: ["cargoWeightTons"],
message: "Enter the total weight in tons.",
});
}
}
const boundBulkPortion = (
on: boolean,
raw: string,

View File

@@ -148,3 +148,62 @@ describe("computeShipmentTotal — with_return surcharge", () => {
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<Freight.IContract>);
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<Freight.IContract>);
const t = computeShipmentTotal(
perTon,
values({ containers: [], cargoWeightTons: "800", itemCount: "" }),
);
expect(line(t, "Bulk freight")).toMatchObject({ quantity: 800, amount: 8000 });
});
});

View File

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