Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-24 02:49:34 +03:00
8 changed files with 338 additions and 64 deletions

View File

@@ -242,12 +242,23 @@ export class BookingPricingService {
) )
: 0; : 0;
// Consolidation is system-managed: the CONSOLIDATION_ENABLED surcharge fires // Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
// whenever any container line leaves a wagon partially filled. Derived from // a container type leaves a wagon partially filled. Aggregate by type first —
// the container quantities there is no persisted opt-in flag. // two lines of the same type share wagons, so 2× 20FT (= one full wagon) must
// NOT count as a partial wagon. Mirrors ConsolidationService.slotsFromContainerLines.
const remainderByType = new Map<string, { quantity: number; perWagon: number }>();
for (const l of lines) {
const prev = remainderByType.get(l.container.containerTypeId);
remainderByType.set(l.container.containerTypeId, {
quantity: (prev?.quantity ?? 0) + Number(l.quantity || 0),
perWagon: l.perWagon,
});
}
const allowConsolidation = const allowConsolidation =
booking.freightType === 'CONTAINER' && booking.freightType === 'CONTAINER' &&
lines.some((l) => wagonRemainder(l.quantity, l.perWagon) > 0); [...remainderByType.values()].some(
(t) => wagonRemainder(t.quantity, t.perWagon) > 0,
);
return { return {
freightType: booking.freightType as 'CONTAINER' | 'BULK', freightType: booking.freightType as 'CONTAINER' | 'BULK',

View File

@@ -57,16 +57,29 @@ export class ConsolidationService {
async slotsFromContainerLines( async slotsFromContainerLines(
lines: Array<{ containerTypeId: string; quantity: number }>, lines: Array<{ containerTypeId: string; quantity: number }>,
): Promise<ConsolidationSlot[]> { ): Promise<ConsolidationSlot[]> {
const slots: ConsolidationSlot[] = []; // Aggregate by container type first: two lines of the same type on one
// booking share the same wagons. Counting them separately would flag a
// self-complete booking (e.g. 2× 20FT = exactly one wagon) as a partial
// wagon and wrongly park it in PENDING_CONSOLIDATION.
const quantityByType = new Map<string, number>();
for (const line of lines) { for (const line of lines) {
const ct = await this.containerTypesService.findById(line.containerTypeId); if (!line.containerTypeId) continue;
quantityByType.set(
line.containerTypeId,
(quantityByType.get(line.containerTypeId) ?? 0) + Number(line.quantity || 0),
);
}
const slots: ConsolidationSlot[] = [];
for (const [containerTypeId, quantity] of quantityByType) {
const ct = await this.containerTypesService.findById(containerTypeId);
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
const remainder = wagonRemainder(line.quantity, perWagon); const remainder = wagonRemainder(quantity, perWagon);
if (remainder === 0) continue; if (remainder === 0) continue;
slots.push({ slots.push({
containerTypeId: line.containerTypeId, containerTypeId,
containerTypeCode: ct.code, containerTypeCode: ct.code,
quantity: line.quantity, quantity,
containersPerWagon: perWagon, containersPerWagon: perWagon,
remainder, remainder,
slotsNeeded: perWagon - remainder, slotsNeeded: perWagon - remainder,

View File

@@ -201,8 +201,15 @@ export class RuleEngineService {
// Surcharges are now self-describing rates: any LIVE rate whose `trigger` // Surcharges are now self-describing rates: any LIVE rate whose `trigger`
// is not ALWAYS. Each fires independently and stacks on top of base freight // is not ALWAYS. Each fires independently and stacks on top of base freight
// — hazard + reefer + overweight all add together, each with its own unit. // — hazard + reefer + overweight all add together, each with its own unit.
//
// A given surcharge identity (same trigger + rateType + unit + value +
// scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
// from a non-idempotent seeder — would otherwise repeat the same surcharge
// many times and inflate the total, so we collapse them to one row each.
const liveRates = await this.ratesRepo.findLiveRates(); const liveRates = await this.ratesRepo.findLiveRates();
const surchargeRates = liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'); const surchargeRates = this.dedupeRatesBySignature(
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
);
for (const rate of surchargeRates) { for (const rate of surchargeRates) {
const triggered = this.matchesTrigger(rate.trigger, { const triggered = this.matchesTrigger(rate.trigger, {
@@ -404,4 +411,34 @@ export class RuleEngineService {
private surchargeCode(rate: Rate): string { private surchargeCode(rate: Rate): string {
return rate.rateType ?? rate.trigger; return rate.rateType ?? rate.trigger;
} }
/**
* Collapse rates that describe the same charge to a single representative.
*
* Two rates are "the same" when they would produce an identical price line:
* same trigger, rateType, unit, value, currency, and scoping (container /
* cargo type). Duplicate rows (e.g. a seeder run more than once) therefore
* stack into one line instead of repeating — keeping the breakdown clean and
* the total correct. The first row of each signature is kept so an existing
* rateId is preserved for snapshotting.
*/
private dedupeRatesBySignature(rates: Rate[]): Rate[] {
const seen = new Set<string>();
const result: Rate[] = [];
for (const rate of rates) {
const signature = [
rate.trigger,
rate.rateType,
rate.rateUnit,
Number(rate.rateValue),
rate.currency,
rate.containerTypeId ?? '',
rate.cargoTypeId ?? '',
].join('|');
if (seen.has(signature)) continue;
seen.add(signature);
result.push(rate);
}
return result;
}
} }

View File

@@ -453,18 +453,50 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
]; ];
const entities = rateData.map((d) => // Idempotent: insert each canonical rate only if no row with the same
rRepo.create({ // signature already exists. Re-running the seeder must NOT accumulate
// duplicate rows — duplicated surcharge rates would otherwise repeat on
// every booking's price breakdown.
const signature = (r: {
rateType: string;
rateUnit: string;
rateValue: number;
currency: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
}) =>
[
r.rateType,
r.rateUnit,
Number(r.rateValue),
r.currency,
r.containerTypeId ?? "",
r.cargoTypeId ?? "",
].join("|");
const existing: Rate[] = await rRepo.find();
const existingBySignature = new Set(existing.map((r) => signature(r)));
const toCreate = rateData
.map((d) => ({
currency: "USD", currency: "USD",
...d, ...d,
status: "LIVE", status: "LIVE" as const,
proposedByStaffId: STAFF_USER_ID, proposedByStaffId: STAFF_USER_ID,
approvedByCeoId: CEO_USER_ID, approvedByCeoId: CEO_USER_ID,
approvedAt: now, approvedAt: now,
effectiveFrom, effectiveFrom,
}), }))
); .filter((d) => !existingBySignature.has(signature(d)));
return rRepo.save(entities);
if (toCreate.length === 0) {
this.logger.log("Rates already seeded — skipping (idempotent)");
return existing;
}
const created = await rRepo.save(toCreate.map((d) => rRepo.create(d)));
this.logger.log(`Seeded ${created.length} new rate(s)`);
return [...existing, ...created];
} }
private async seedDraftBookings( private async seedDraftBookings(

View File

@@ -115,8 +115,9 @@ export function PaymentCard({
pricing: Pricing; pricing: Pricing;
}) { }) {
const paid = booking.paymentStatus === "PAID"; const paid = booking.paymentStatus === "PAID";
// Customer sees the grand total only. A staff adjustment, when present, // Customer sees the grand total plus the price breakdown that makes it up.
// overrides the computed total and is flagged with an "Adjusted by EDR" badge. // A staff adjustment, when present, overrides the computed total and is
// flagged with an "Adjusted by EDR" badge.
const isAdjusted = const isAdjusted =
booking.adjustedTotalAmount !== null && booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined; booking.adjustedTotalAmount !== undefined;
@@ -124,6 +125,7 @@ export function PaymentCard({
const total = isAdjusted const total = isAdjusted
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
: priceTotal(pricing); : priceTotal(pricing);
const hasItems = priceLineItems(pricing).length > 0;
return ( return (
<SectionCard p={22}> <SectionCard p={22}>
@@ -183,6 +185,25 @@ export function PaymentCard({
</Text> </Text>
)} )}
</Box> </Box>
{hasItems && (
<>
<Divider />
<LineItems pricing={pricing} />
<Group
justify="space-between"
mt={12}
pt={14}
style={{ borderTop: "1px solid #EEF2F6" }}
>
<Text fz="14px" fw={800} c="#10202F">
{isAdjusted ? "Adjusted total" : "Total"}
</Text>
<Text fz="15px" fw={800} c="#10202F">
{total}
</Text>
</Group>
</>
)}
{/* <Button */} {/* <Button */}
{/* fullWidth */} {/* fullWidth */}
{/* mt={16} */} {/* mt={16} */}

View File

@@ -23,6 +23,7 @@ import {
Check, Check,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Link2,
Send, Send,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
@@ -177,6 +178,12 @@ export default function NewBookingPage() {
} }
setPriceModalMode(null); setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
// A partial-wagon booking is parked until a partner is found — explain the
// wait in a modal before sending the customer to the detail page.
if (result.status === "PENDING_CONSOLIDATION") {
setConsolidationPending(true);
return;
}
navigate(`/bookings/${priceBookingId}`); navigate(`/bookings/${priceBookingId}`);
}, },
}); });
@@ -186,10 +193,14 @@ export default function NewBookingPage() {
if (!priceBookingId) throw new Error("No booking to confirm"); if (!priceBookingId) throw new Error("No booking to confirm");
return api.bookings.confirmSubmit.call({ id: priceBookingId }); return api.bookings.confirmSubmit.call({ id: priceBookingId });
}, },
onSuccess: () => { onSuccess: (result) => {
setPriceChangeResult(null); setPriceChangeResult(null);
setPriceModalMode(null); setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
if (result.status === "PENDING_CONSOLIDATION") {
setConsolidationPending(true);
return;
}
navigate(`/bookings/${priceBookingId}`); navigate(`/bookings/${priceBookingId}`);
}, },
}); });
@@ -262,20 +273,25 @@ export default function NewBookingPage() {
return route; return route;
}, [originYard, destinationYard]); }, [originYard, destinationYard]);
// Operations the customer may book, gated by the company's onboarded profiles. // The company's onboarded profile types — drives which operations are offered
const allowedOperations = useMemo<OperationType[]>(() => { // and which profile each operation stamps the booking to.
const profileTypes = (auth.company?.company?.companyProfiles ?? []).map( const profileTypes = useMemo(
(p) => p.type, () => (auth.company?.company?.companyProfiles ?? []).map((p) => p.type),
); [auth.company],
return allowedOperationsForProfiles(profileTypes); );
}, [auth.company]);
// Stamp the booking to the right operational profile. Import/Export switch the // Operations the customer may book, gated by the company's onboarded profiles.
// active mode so the matching onboarding documents are attached; Intercity uses const allowedOperations = useMemo<OperationType[]>(
// whatever profile is already active. () => allowedOperationsForProfiles(profileTypes),
[profileTypes],
);
// Stamp the booking to the right operational profile. Import/Export (and their
// "as FF" variants) switch the active mode so the matching onboarding documents
// are attached; Intercity uses whatever profile is already active.
const handleOperationSelect = (op: OperationType) => { const handleOperationSelect = (op: OperationType) => {
if (op === "intercity") return; if (op === "intercity") return;
const target = operationToProfileType(op); const target = operationToProfileType(op, profileTypes);
if (auth.activeProfileType !== target) { if (auth.activeProfileType !== target) {
void auth.switchMode(target as never); void auth.switchMode(target as never);
} }
@@ -301,6 +317,8 @@ export default function NewBookingPage() {
useState<SubmitBookingResponse | null>(null); useState<SubmitBookingResponse | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState(""); const [cancelReason, setCancelReason] = useState("");
// Shown when a submitted booking is parked waiting for a consolidation partner.
const [consolidationPending, setConsolidationPending] = useState(false);
async function handleContinue() { async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true }); const valid = await form.trigger(stepFields[step], { shouldFocus: true });
@@ -804,6 +822,66 @@ export default function NewBookingPage() {
)} )}
</Modal> </Modal>
<Modal
opened={consolidationPending}
onClose={() => {
setConsolidationPending(false);
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
}}
title={
<Group gap={10} wrap="nowrap">
<div
className="flex shrink-0 items-center justify-center rounded-[11px] border border-[#F4D9A8]"
style={{ width: 38, height: 38, backgroundColor: "#FDF3E0", color: "#C77F12" }}
>
<Link2 size={20} />
</div>
<Text fw={700}>Waiting to share a wagon</Text>
</Group>
}
radius="lg"
centered
size="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Your booking is submitted, but your cargo only fills part of a wagon.
We&apos;re pairing it with another shipment on the same route to share
the space.
</Text>
<Box
p="md"
style={{
borderRadius: 12,
border: "1px solid #F4D9A8",
background: "#FDF3E0",
}}
>
<Text size="sm" fw={600} c="#10202F">
What happens next
</Text>
<Text size="sm" mt={4} c="#7A6A4E">
As soon as a matching shipment is found, your booking continues
automatically you&apos;ll be notified, and no action is needed
from you until then. Your acceptance, approval and contract stay
independent and yours alone.
</Text>
</Box>
<Group justify="flex-end" gap="sm">
<Button
color="edr-green"
radius="md"
onClick={() => {
setConsolidationPending(false);
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
}}
>
Got it
</Button>
</Group>
</Stack>
</Modal>
<Modal <Modal
opened={cancelDialogOpen} opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)} onClose={() => setCancelDialogOpen(false)}

View File

@@ -13,7 +13,16 @@ export const STEPS = [
{ id: 7, label: "Review & Submit", short: "Submit" }, { id: 7, label: "Review & Submit", short: "Submit" },
] as const; ] as const;
export const OPERATION_TYPES = ["import", "export", "intercity"] as const; export const OPERATION_TYPES = [
"import",
"export",
"intercity",
// Freight-forwarder variants: same trade direction as import/export but the
// booking is stamped to the company's freight_forwarder profile instead of a
// direct importer/exporter profile.
"import_ff",
"export_ff",
] as const;
export type OperationType = (typeof OPERATION_TYPES)[number]; export type OperationType = (typeof OPERATION_TYPES)[number];
/** /**
@@ -341,10 +350,15 @@ export function getRouteDirection(
/** /**
* Operations a company may book, derived from its onboarded profile types. * Operations a company may book, derived from its onboarded profile types.
* - freight forwarder (or DJ forwarder) → import, export, intercity *
* - importer → import, intercity * - pure freight forwarder → import, export, intercity
* - exporter → export, intercity * (these run as FF: the booking is stamped to the freight_forwarder profile)
* - importer + exporter → import, export, intercity * - importer → import, intercity
* - exporter → export, intercity
* - importer + exporter → import, export, intercity
* - importer (+/- exporter) + FF → direct import/export PLUS the matching
* "as FF" variants, so the company can book either directly or as a forwarder
*
* Intercity (DOMESTIC) is always available to any customer-side profile. * Intercity (DOMESTIC) is always available to any customer-side profile.
*/ */
export function allowedOperationsForProfiles( export function allowedOperationsForProfiles(
@@ -352,26 +366,75 @@ export function allowedOperationsForProfiles(
): OperationType[] { ): OperationType[] {
const has = (t: string) => profileTypes.includes(t); const has = (t: string) => profileTypes.includes(t);
const isForwarder = has("freight_forwarder") || has("dj_freight_forwarder"); const isForwarder = has("freight_forwarder") || has("dj_freight_forwarder");
const isImporter = has("importer");
const isExporter = has("exporter");
const isDirect = isImporter || isExporter;
const ops = new Set<OperationType>(); const ops = new Set<OperationType>();
if (isForwarder || has("importer")) ops.add("import");
if (isForwarder || has("exporter")) ops.add("export"); // Direct importer/exporter capabilities.
// Any importer/exporter/forwarder profile can also run domestic (intercity). if (isImporter) ops.add("import");
if (isForwarder || has("importer") || has("exporter")) ops.add("intercity"); if (isExporter) ops.add("export");
if (isForwarder) {
if (isDirect) {
// Mixed: keep the direct options above and add explicit "as FF" variants
// so the customer can disambiguate which profile the booking belongs to.
ops.add("import_ff");
ops.add("export_ff");
} else {
// Pure forwarder: shows plain Import/Export/Intercity, but these run on the
// freight_forwarder profile (see operationToProfileType).
ops.add("import");
ops.add("export");
}
}
// Any customer-side profile can also run domestic (intercity).
if (isForwarder || isDirect) ops.add("intercity");
// Preserve a stable display order. // Preserve a stable display order.
return OPERATION_TYPES.filter((o) => ops.has(o)); return OPERATION_TYPES.filter((o) => ops.has(o));
} }
/**
* Whether this operation runs on the freight_forwarder profile. True for the
* explicit FF variants, and for plain import/export when the company is a pure
* forwarder (no direct importer/exporter profile).
*/
export function isForwarderOperation(
op: OperationType,
profileTypes: string[],
): boolean {
if (op === "import_ff" || op === "export_ff") return true;
const has = (t: string) => profileTypes.includes(t);
const isForwarder = has("freight_forwarder") || has("dj_freight_forwarder");
const isDirect = has("importer") || has("exporter");
if ((op === "import" || op === "export") && isForwarder && !isDirect) {
return true;
}
return false;
}
/** Trade direction the backend will derive for a given operation type. */ /** Trade direction the backend will derive for a given operation type. */
export function operationToTradeDirection( export function operationToTradeDirection(
op: OperationType, op: OperationType,
): Freight.ScheduleTradeDirection { ): Freight.ScheduleTradeDirection {
if (op === "import") return "IMPORT"; if (op === "import" || op === "import_ff") return "IMPORT";
if (op === "export") return "EXPORT"; if (op === "export" || op === "export_ff") return "EXPORT";
return "DOMESTIC"; return "DOMESTIC";
} }
/** The company_profile type a booking for this operation should be stamped to. */ /**
export function operationToProfileType(op: OperationType): string { * The company_profile type a booking for this operation should be stamped to.
* FF variants (and a pure forwarder's plain import/export) → freight_forwarder.
*/
export function operationToProfileType(
op: OperationType,
profileTypes: string[] = [],
): string {
if (op === "import_ff" || op === "export_ff") return "freight_forwarder";
if (isForwarderOperation(op, profileTypes)) return "freight_forwarder";
if (op === "import") return "importer"; if (op === "import") return "importer";
if (op === "export") return "exporter"; if (op === "export") return "exporter";
return "freight_forwarder"; return "freight_forwarder";

View File

@@ -1,5 +1,11 @@
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { ArrowDownToLine, ArrowUpFromLine, Truck } from "lucide-react"; import {
ArrowDownToLine,
ArrowUpFromLine,
PackageCheck,
PackageOpen,
Truck,
} from "lucide-react";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import { import {
BookingFormInputValues, BookingFormInputValues,
@@ -52,6 +58,22 @@ const OPTIONS: Array<{
iconBg: "#F1ECFB", iconBg: "#F1ECFB",
iconColor: "#6A40B8", iconColor: "#6A40B8",
}, },
{
value: "import_ff",
title: "Import as FF",
description: "Import handled on behalf of a client as a freight forwarder.",
icon: <PackageOpen className="h-5 w-5" />,
iconBg: "#ECF6F1",
iconColor: "#0A6F4D",
},
{
value: "export_ff",
title: "Export as FF",
description: "Export handled on behalf of a client as a freight forwarder.",
icon: <PackageCheck className="h-5 w-5" />,
iconBg: "#EAF1FB",
iconColor: "#2E5B96",
},
]; ];
export function Step0OperationType({ export function Step0OperationType({
@@ -84,26 +106,23 @@ export function Step0OperationType({
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div> <div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
{OPTIONS.map((opt) => { {OPTIONS.filter((opt) =>
const enabled = allowedOperations.includes(opt.value); allowedOperations.includes(opt.value),
return ( ).map((opt) => (
<OptionCard <OptionCard
key={opt.value} key={opt.value}
selected={field.value === opt.value} selected={field.value === opt.value}
disabled={!enabled} icon={opt.icon}
icon={opt.icon} iconBg={opt.iconBg}
iconBg={opt.iconBg} iconColor={opt.iconColor}
iconColor={opt.iconColor} title={opt.title}
title={opt.title} description={opt.description}
description={opt.description} onClick={() => {
onClick={() => { field.onChange(opt.value);
if (!enabled) return; onSelect?.(opt.value);
field.onChange(opt.value); }}
onSelect?.(opt.value); />
}} ))}
/>
);
})}
</div> </div>
<OptionFieldError error={fieldState.error} /> <OptionFieldError error={fieldState.error} />
</div> </div>