diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 5c9eea580..276761ae0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -242,12 +242,23 @@ export class BookingPricingService { ) : 0; - // Consolidation is system-managed: the CONSOLIDATION_ENABLED surcharge fires - // whenever any container line leaves a wagon partially filled. Derived from - // the container quantities — there is no persisted opt-in flag. + // Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever + // a container type leaves a wagon partially filled. Aggregate by type first — + // 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(); + 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 = booking.freightType === 'CONTAINER' && - lines.some((l) => wagonRemainder(l.quantity, l.perWagon) > 0); + [...remainderByType.values()].some( + (t) => wagonRemainder(t.quantity, t.perWagon) > 0, + ); return { freightType: booking.freightType as 'CONTAINER' | 'BULK', diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index 541d5d09f..e16b97997 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -57,16 +57,29 @@ export class ConsolidationService { async slotsFromContainerLines( lines: Array<{ containerTypeId: string; quantity: number }>, ): Promise { - 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(); 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 remainder = wagonRemainder(line.quantity, perWagon); + const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ - containerTypeId: line.containerTypeId, + containerTypeId, containerTypeCode: ct.code, - quantity: line.quantity, + quantity, containersPerWagon: perWagon, remainder, slotsNeeded: perWagon - remainder, diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 2390c3f8e..4b0e8ccb3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -201,8 +201,15 @@ export class RuleEngineService { // Surcharges are now self-describing rates: any LIVE rate whose `trigger` // is not ALWAYS. Each fires independently and stacks on top of base freight // — 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 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) { const triggered = this.matchesTrigger(rate.trigger, { @@ -404,4 +411,34 @@ export class RuleEngineService { private surchargeCode(rate: Rate): string { 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(); + 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; + } } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index c0d69b79b..6f44baaef 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -447,18 +447,50 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, ]; - const entities = rateData.map((d) => - rRepo.create({ + // Idempotent: insert each canonical rate only if no row with the same + // 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", ...d, - status: "LIVE", + status: "LIVE" as const, proposedByStaffId: STAFF_USER_ID, approvedByCeoId: CEO_USER_ID, approvedAt: now, effectiveFrom, - }), - ); - return rRepo.save(entities); + })) + .filter((d) => !existingBySignature.has(signature(d))); + + 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( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx index 2148dd5f5..93d843065 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx @@ -115,8 +115,9 @@ export function PaymentCard({ pricing: Pricing; }) { const paid = booking.paymentStatus === "PAID"; - // Customer sees the grand total only. A staff adjustment, when present, - // overrides the computed total and is flagged with an "Adjusted by EDR" badge. + // Customer sees the grand total plus the price breakdown that makes it up. + // A staff adjustment, when present, overrides the computed total and is + // flagged with an "Adjusted by EDR" badge. const isAdjusted = booking.adjustedTotalAmount !== null && booking.adjustedTotalAmount !== undefined; @@ -124,6 +125,7 @@ export function PaymentCard({ const total = isAdjusted ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` : priceTotal(pricing); + const hasItems = priceLineItems(pricing).length > 0; return ( @@ -183,6 +185,25 @@ export function PaymentCard({ )} + {hasItems && ( + <> + + + + + {isAdjusted ? "Adjusted total" : "Total"} + + + {total} + + + + )} {/* + + + + setCancelDialogOpen(false)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index b7252946c..612ec2ad8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -13,7 +13,16 @@ export const STEPS = [ { id: 7, label: "Review & Submit", short: "Submit" }, ] 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]; /** @@ -341,10 +350,15 @@ export function getRouteDirection( /** * Operations a company may book, derived from its onboarded profile types. - * - freight forwarder (or DJ forwarder) → import, export, intercity - * - importer → import, intercity - * - exporter → export, intercity - * - importer + exporter → import, export, intercity + * + * - pure freight forwarder → import, export, intercity + * (these run as FF: the booking is stamped to the freight_forwarder profile) + * - 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. */ export function allowedOperationsForProfiles( @@ -352,26 +366,75 @@ export function allowedOperationsForProfiles( ): OperationType[] { const has = (t: string) => profileTypes.includes(t); 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(); - if (isForwarder || has("importer")) ops.add("import"); - if (isForwarder || has("exporter")) ops.add("export"); - // Any importer/exporter/forwarder profile can also run domestic (intercity). - if (isForwarder || has("importer") || has("exporter")) ops.add("intercity"); + + // Direct importer/exporter capabilities. + if (isImporter) ops.add("import"); + 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. 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. */ export function operationToTradeDirection( op: OperationType, ): Freight.ScheduleTradeDirection { - if (op === "import") return "IMPORT"; - if (op === "export") return "EXPORT"; + if (op === "import" || op === "import_ff") return "IMPORT"; + if (op === "export" || op === "export_ff") return "EXPORT"; 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 === "export") return "exporter"; return "freight_forwarder"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx index 6bb1643ec..75084bdbd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx @@ -1,5 +1,11 @@ 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 { BookingFormInputValues, @@ -52,6 +58,22 @@ const OPTIONS: Array<{ iconBg: "#F1ECFB", iconColor: "#6A40B8", }, + { + value: "import_ff", + title: "Import as FF", + description: "Import handled on behalf of a client as a freight forwarder.", + icon: , + iconBg: "#ECF6F1", + iconColor: "#0A6F4D", + }, + { + value: "export_ff", + title: "Export as FF", + description: "Export handled on behalf of a client as a freight forwarder.", + icon: , + iconBg: "#EAF1FB", + iconColor: "#2E5B96", + }, ]; export function Step0OperationType({ @@ -84,26 +106,23 @@ export function Step0OperationType({ render={({ field, fieldState }) => (
- {OPTIONS.map((opt) => { - const enabled = allowedOperations.includes(opt.value); - return ( - { - if (!enabled) return; - field.onChange(opt.value); - onSelect?.(opt.value); - }} - /> - ); - })} + {OPTIONS.filter((opt) => + allowedOperations.includes(opt.value), + ).map((opt) => ( + { + field.onChange(opt.value); + onSelect?.(opt.value); + }} + /> + ))}