This commit is contained in:
Marshal
2026-07-23 14:18:24 +00:00
parent 15a6bab5d0
commit 127676055b
15 changed files with 362 additions and 160 deletions

View File

@@ -128,7 +128,10 @@ export function computeGlShipmentTotal(
const qty = q.bulkQuantity;
const rate =
rateFor(
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance,
(i) =>
(i.unit === "per_ton" || i.unit === "per_item") &&
!i.isClearance &&
!i.conditionalOn,
) ?? items[0];
if (rate && qty > 0) {
lines.push({
@@ -165,6 +168,23 @@ export function computeGlShipmentTotal(
}
}
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
const tons = q.bulkQuantity;
if (tons > 0) {
lines.push({
label: lashing.label,
unitPrice: lashing.unitPrice,
unit: lashing.unit,
quantity: tons,
amount: lashing.unitPrice * tons,
});
}
}
// Customs clearance service fee — billed on the booking invoice with the
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on

View File

@@ -213,6 +213,7 @@ const RuleEngineFormDialog = ({
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
if (name === "cargoKind") {
next.containerTypeId = "";
next.cargoTypeId = "";
next.rateUnit = "";
}
return next;
@@ -339,6 +340,10 @@ const RuleEngineFormDialog = ({
value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading}
// Native required blocks submit while a mandatory select is empty —
// without it the form posts and the API 400s (e.g. a container
// customs/lashing rate with no container type picked).
required={field.required}
data={options
.filter((opt) => opt.value !== "")
.map((opt) => ({

View File

@@ -172,7 +172,7 @@ const RATE_TRIGGERS = [
},
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Penalty", value: "CONSOLIDATION" },
{ label: "Lashing (per container type / bulk)", value: "LASHING" },
{ label: "Lashing (bulk, per cargo type)", value: "LASHING" },
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
@@ -229,11 +229,13 @@ const allowedRateUnits = (
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
case "CUSTOMS_CLEARANCE":
case "LASHING":
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return cargoKind === "BULK"
? ["PER_TON", "PER_WAGON"]
: ["PER_CONTAINER", "PER_WAGON"];
case "LASHING":
// Bulk-only cargo securing — per ton or per wagon.
return ["PER_TON", "PER_WAGON"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
@@ -722,10 +724,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
String(v.trigger ?? ""),
)),
},
// ── Cargo kind — customs clearance and lashing are priced separately
// for containers (one rate per container type) and bulk ────────────────
// ── Cargo kind — customs clearance is priced separately for containers
// (one rate per container type) and bulk ───────────────────────────────
{
name: "cargoKind",
label: "Cargo kind",
@@ -736,8 +740,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
description:
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
showIf: (v) =>
v.appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")),
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
// Not a stored column: a container fee carries its containerTypeId, a
// bulk fee carries none.
getInitialValue: (record) =>
@@ -752,9 +755,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Which container type this fee covers",
showIf: (v) =>
v.appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")) &&
v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "CONTAINER",
},
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
{
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
required: true,
placeholder: "Which bulk commodity this fee covers",
showIf: (v) =>
v.appliesTo === "OTHER" &&
v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "BULK",
},
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
// commodity (specific wins over the commodity-wide catch-all) ──────────
{
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
optional: true,
placeholder: "All lashing commodities (optional)",
description:
"Leave empty to cover every lashing commodity; a commodity-specific rate wins over the catch-all.",
showIf: (v) =>
v.appliesTo === "OTHER" && v.trigger === "LASHING",
},
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
{
name: "intercityKind",

View File

@@ -19,6 +19,7 @@ import {
Title,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
AlertCircle,
Check,
@@ -218,6 +219,12 @@ export default function NewContractPage({
},
});
// 422 from generate-price = a required rate isn't configured for the chosen
// route — the customer can't fix it, so it's surfaced as a blocking modal.
const rateNotConfigured =
isAxiosError(persistAndPriceMutation.error) &&
persistAndPriceMutation.error.response?.status === 422;
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceContractId) throw new Error("No contract to confirm");
@@ -759,7 +766,7 @@ export default function NewContractPage({
<StepIndicator step={step} steps={visibleSteps} />
</Box>
{persistAndPriceMutation.isError && (
{persistAndPriceMutation.isError && !rateNotConfigured && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
@@ -777,6 +784,28 @@ export default function NewContractPage({
</Alert>
)}
{/* Pricing not configured (422) — a rate is missing on the chosen
route, nothing the customer can fix. Block with a modal. */}
<Modal
opened={rateNotConfigured}
onClose={() => persistAndPriceMutation.reset()}
title="Contract creation unavailable"
centered
>
<Stack gap="sm">
<Text size="sm">
You can&apos;t create a contract right now pricing hasn&apos;t
been configured for the selected route yet. Please contact
support for assistance.
</Text>
<Group justify="flex-end">
<Button onClick={() => persistAndPriceMutation.reset()}>
OK
</Button>
</Group>
</Stack>
</Modal>
{/* Step 0 — Setup: operation, contract, service, currency, miles. */}
{step === 0 && (
<StepCard>

View File

@@ -111,7 +111,10 @@ export function computeShipmentTotal(
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
const rate =
rateFor(
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance,
(i) =>
(i.unit === "per_ton" || i.unit === "per_item") &&
!i.isClearance &&
!i.conditionalOn,
) ?? items[0];
if (rate && qty > 0) {
lines.push({
@@ -150,6 +153,23 @@ export function computeShipmentTotal(
}
}
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
const tons = Number(values.cargoWeightTons || 0);
if (tons > 0) {
lines.push({
label: lashing.label,
unitPrice: lashing.unitPrice,
unit: lashing.unit,
quantity: tons,
amount: lashing.unitPrice * tons,
});
}
}
// Customs clearance service fee — billed on the booking invoice with the
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on