mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Add a minimal payment mock server for end-to-end testing
This commit is contained in:
@@ -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 }) =>
|
||||
|
||||
@@ -182,18 +182,18 @@ const FreightDashboardHeader = ({
|
||||
)}
|
||||
</Box>
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => navigate("/dashboard/profile#signature")}
|
||||
>
|
||||
Signature & 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} />}
|
||||
|
||||
@@ -548,18 +548,18 @@ export function AppLayout({
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => navigate("/signature")}
|
||||
>
|
||||
Signature & 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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user