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

revert back the clerance payment
This commit is contained in:
marshal
2026-07-23 16:56:56 +03:00
committed by GitHub
54 changed files with 1222 additions and 712 deletions

View File

@@ -65,8 +65,12 @@ export function computeGlShipmentTotal(
(i) =>
i.containerSize === line.containerSize &&
i.unit === "per_container" &&
!i.conditionalOn,
) ?? rateFor((i) => i.containerSize === line.containerSize);
!i.conditionalOn &&
!i.isClearance,
) ??
rateFor(
(i) => i.containerSize === line.containerSize && !i.isClearance,
);
if (rate) {
lines.push({
label: rate.label,
@@ -123,7 +127,9 @@ export function computeGlShipmentTotal(
} else {
const qty = q.bulkQuantity;
const rate =
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
rateFor(
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance,
) ?? items[0];
if (rate && qty > 0) {
lines.push({
label: rate.label,
@@ -159,6 +165,36 @@ export function computeGlShipmentTotal(
}
}
// 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
// the wagon capacity the train stocks — shown at real pricing, not estimated.
for (const cl of items.filter((i) => i.isClearance)) {
let qty = 0;
if (q.isContainer) {
const boxes = q.containers
.filter((c) => c.containerSize === cl.containerSize)
.reduce((s, c) => s + Number(c.quantity || 0), 0);
qty =
cl.unit === "per_wagon"
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton") {
qty = q.bulkQuantity;
} else if (cl.unit === "flat") {
qty = 1;
}
if (qty > 0) {
lines.push({
label: cl.label,
unitPrice: cl.unitPrice,
unit: cl.unit,
quantity: qty,
amount: cl.unitPrice * qty,
});
}
}
const total = lines.reduce((s, l) => s + l.amount, 0);
return { currency, lines, total };
}
@@ -167,6 +203,7 @@ export function computeGlShipmentTotal(
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
const map: Record<string, string> = {
per_container: "container",
per_wagon: "wagon",
per_ton: "ton",
per_item: "item",
per_km: "km",

View File

@@ -209,6 +209,12 @@ const RuleEngineFormDialog = ({
next.containerTypeId = "";
next.cargoTypeId = "";
}
// Cargo kind (customs / lashing) decides both the container-type scope
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
if (name === "cargoKind") {
next.containerTypeId = "";
next.rateUnit = "";
}
return next;
});
};

View File

@@ -286,7 +286,6 @@ export const BOOKING_LIST_TABS = [
key: "clearance",
label: "Clearance",
statuses: [
"AWAITING_CLEARANCE_PAYMENT",
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",

View File

@@ -51,10 +51,6 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Active",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
},
AWAITING_CLEARANCE_PAYMENT: {
label: "Clearance Fee Due",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
AWAITING_CLEARANCE_DOCUMENTS: {
label: "Awaiting Documents",
color: "bg-amber-50 text-amber-700 border-amber-200",
@@ -122,7 +118,6 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo",
CONTRACT_ACTIVE: "edr-green",
AWAITING_CLEARANCE_PAYMENT: "orange",
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
CLEARANCE_UNDER_REVIEW: "yellow",
CLEARANCE_READY_FOR_BOOKING: "edr-green",
@@ -212,13 +207,6 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
color: "text-[color:var(--freight-brand)]",
stage: 3,
},
AWAITING_CLEARANCE_PAYMENT: {
title: "Clearance Fee Due",
description:
"Customer must pay the prepaid clearance service fee before uploading documents.",
color: "text-orange-600",
stage: 3,
},
AWAITING_CLEARANCE_DOCUMENTS: {
title: "Awaiting Documents",
description: "Customer is uploading pre-booking clearance documents.",

View File

@@ -25,6 +25,8 @@ const FIELD_LABELS: Record<string, string> = {
tradeDirection: "Direction",
containerTypeId: "Container type",
cargoTypeId: "Cargo type",
originYardId: "Origin yard",
destinationYardId: "Destination yard",
};
const fmtDateTime = (iso: string) =>
@@ -36,12 +38,20 @@ const fmtDateTime = (iso: string) =>
hour12: false,
});
const fmtValue = (field: string, value: unknown): string => {
const fmtValue = (
field: string,
value: unknown,
labels?: Record<string, string>,
): string => {
if (value === null || value === undefined || value === "") return "—";
if (field === "rateValue") {
const num = Number(value);
return Number.isNaN(num) ? String(value) : num.toLocaleString();
}
// Yard ids are unreadable — an approver decides on the route, not a UUID.
if (field === "originYardId" || field === "destinationYardId") {
return labels?.[String(value)] ?? String(value);
}
return String(value).replace(/_/g, " ");
};
@@ -77,6 +87,8 @@ interface RateApprovalsSectionProps {
canDecide: boolean;
approve: Decide;
reject: Decide;
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
yardLabels?: Record<string, string>;
}
/**
@@ -89,6 +101,7 @@ const RateApprovalsSection = ({
canDecide,
approve,
reject,
yardLabels,
}: RateApprovalsSectionProps) => {
const [openId, setOpenId] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
@@ -212,11 +225,11 @@ const RateApprovalsSection = ({
{FIELD_LABELS[field] ?? field}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue(field, r.previousValues[field])}
{fmtValue(field, r.previousValues[field], yardLabels)}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{fmtValue(field, r.payload[field])}
{fmtValue(field, r.payload[field], yardLabels)}
</Text>
</Group>
))}

View File

@@ -269,6 +269,10 @@ const RuleEngineResourcePage = () => {
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
const yardLabelById = useMemo(
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
[yardOptions],
);
const usesApprovalRoleField = Boolean(
config?.formFields.some(
(f) => f.name === "requiredRole" || f.name === "blocksRole",
@@ -661,6 +665,7 @@ const RuleEngineResourcePage = () => {
canDecide={canApproveRates}
approve={rateChangeWorkflow.approve}
reject={rateChangeWorkflow.reject}
yardLabels={yardLabelById}
/>
) : null}

View File

@@ -171,12 +171,11 @@ const RATE_TRIGGERS = [
value: "WITH_RETURN",
},
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" },
{ label: "Lashing (flat, per booking)", value: "LASHING" },
{ label: "Penalty", value: "CONSOLIDATION" },
{ label: "Lashing (per container type / bulk)", value: "LASHING" },
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Demurrage", value: "DEMURRAGE" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
];
/**
@@ -211,7 +210,11 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
*/
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
const allowedRateUnits = (
appliesTo: string,
trigger: string,
cargoKind = "",
): string[] => {
if (appliesTo === "OTHER") {
switch (trigger) {
case "OVERWEIGHT":
@@ -221,16 +224,16 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
case "DEMURRAGE":
return ["PER_CONTAINER", "PER_TON"];
case "WITH_RETURN":
// Container-only service — bills per returned container.
return ["PER_CONTAINER", "FLAT"];
// Container-only service — per returned container, per wagon, or flat.
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
case "CUSTOMS_CLEARANCE":
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
return ["FLAT"];
case "LASHING":
// Flat cargo-securing fee, billed once per booking.
return ["FLAT"];
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return cargoKind === "BULK"
? ["PER_TON", "PER_WAGON"]
: ["PER_CONTAINER", "PER_WAGON"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
@@ -258,7 +261,11 @@ const rateUnitOptions = (values: Record<string, unknown>) => {
const appliesTo = String(values.appliesTo ?? "");
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
if (!appliesTo) return [];
return allowedRateUnits(appliesTo, trigger).map(unitOption);
return allowedRateUnits(
appliesTo,
trigger,
String(values.cargoKind ?? ""),
).map(unitOption);
};
const CURRENCIES = [
@@ -659,7 +666,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
filters: {
appliesTo: "OTHER",
trigger:
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE",
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
},
},
],
@@ -717,6 +724,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
},
// ── Cargo kind — customs clearance and lashing are priced separately
// for containers (one rate per container type) and bulk ────────────────
{
name: "cargoKind",
label: "Cargo kind",
type: "select",
required: true,
options: INTERCITY_KINDS,
placeholder: "Is this fee for containers or bulk?",
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 ?? "")),
// Not a stored column: a container fee carries its containerTypeId, a
// bulk fee carries none.
getInitialValue: (record) =>
record.containerTypeId ? "CONTAINER" : "BULK",
},
// ── Container type — a container fee names the type it covers ─────────
{
name: "containerTypeId",
label: "Container type",
type: "select",
required: true,
placeholder: "Which container type this fee covers",
showIf: (v) =>
v.appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")) &&
v.cargoKind === "CONTAINER",
},
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
{
name: "intercityKind",

View File

@@ -28,7 +28,6 @@ export const BOOKING_STATUSES = [
"CONTRACT_ACTIVE",
"CONTRACT_CLOSED",
// Post counter-sign document-clearance gate.
"AWAITING_CLEARANCE_PAYMENT",
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",