Marketplace Logistics¶
Purpose¶
A vendor-neutral shipment/tracking layer that sits between seller fulfilment and delivery: once a
seller order is packed and ready, a Shipment aggregate tracks it from booking through pickup,
transit, and delivery, syncing back into the existing fulfilment pipeline as it goes. No real
courier API is called anywhere in this codebase - no Shiprocket, Delhivery, Blue Dart, DTDC, or
India Post integration exists. Builds on
Seller Order Fulfilment and reads (never rewrites)
Payments & Seller Settlements's settlement generation to optionally
reimburse a seller's actual shipping cost.
Shipment flow¶
SellerOrder (ReadyToShip - see seller-fulfilment.md)
|
v
Shipment.Create() snapshots Warehouse + Order.ShippingAddress, starts Draft
|
v
ILogisticsProvider.BookShipmentAsync() vendor-neutral - Manual or Test provider only
|
v
Shipment.ApplyProviderResult() records TrackingNumber/AwbNumber/EstimatedDeliveryDate/ShippingCost
|
v
ReadyForPickup -> PickupScheduled -> PickedUp --------> SellerOrder.Ship(...) (ReadyToShip -> Shipped)
|
v
InTransit -> OutForDelivery -> Delivered -------------> SellerOrder.Deliver() + Order.RecalculateStatusFromSellerOrders()
Cancellation is a separate branch, only reachable before pickup (Draft/ReadyForPickup/ PickupScheduled) - see "Cancellation" below.
Provider abstraction¶
ILogisticsProvider (ECommerce.Application/Common/Interfaces/ILogisticsProvider.cs) is the only
seam through which a shipment gets booked, scheduled, or cancelled with "the courier". Nothing in
the Domain or Application layer contains a real vendor's name, DTO shape, secret, or endpoint URL.
Two implementations exist, both vendor-free:
ManualLogisticsProvider(LogisticsProvider.Manual) - the real day-one flow. Operations staff arrange the courier themselves (phone call, drop-off) outside this system; the provider's job is only to generate a platform tracking number (reusing the shipment number, since there is no real courier response to take one from) and a config-driven cost estimate (Marketplace:Logistics:Manual:BaseRate+PerKgRate * weight,EstimatedDeliveryDays). No AWB is ever produced by this provider.MockLogisticsProvider(LogisticsProvider.Test) - fully deterministic, zero configuration dependency, used only by backend tests and the local E2E smoke test so assertions never depend onManualLogisticsProvider's rate math. Never selectable from the API -LogisticsControlleralways books throughLogisticsProvider.Manual.
A real vendor adapter is intentionally not implemented in this slice. Adding one later means
writing a new ILogisticsProvider implementation and registering it in
ECommerce.Infrastructure/DependencyInjection.cs - no other layer should need to change.
AWB vs tracking number¶
AwbNumber is the courier's own consignment/airway-bill number - it may be null (e.g.
ManualLogisticsProvider never has one to report). TrackingNumber is the platform-facing
identifier customers and sellers actually use, and is always present once a shipment is booked.
The two are deliberately separate fields; nothing in this codebase assumes they're ever equal.
Shipment domain model¶
Shipment (ECommerce.Domain/Entities/Shipment.cs) has no public mutable setters - every field
changes only through a named method that validates its own precondition:
- Creation (
Create) snapshots the pickup warehouse (PickupWarehouseName/AddressLine1/ AddressLine2/City/State/PostalCode/Country- exactly the fieldsWarehousehas today; noContactName/Phonewere invented) and the delivery address (DeliveryFullName/Street/City/ State/PostalCode/Country/Phone- exactlyOrderShippingAddress's fields) at creation time, so a later warehouse edit or address change never rewrites shipping history. ApplyProviderResultis a second construction step, called immediately afterCreateonce the provider has responded - a client can never supplyAwbNumber/TrackingNumber/ShippingCostdirectly.- Lifecycle:
Draft -> ReadyForPickup -> PickupScheduled -> PickedUp -> InTransit -> OutForDelivery -> Delivered, plusCancel(only before pickup). Every transition method accepts exactly one predecessor status and throws before any side effect - idempotent-safe by construction, the same patternSellerOrder's fulfilment methods use. ShipmentTrackingEventis append-only: no update method exists anywhere on the type. Every transition (including creation) appends one event; the timeline a seller/customer sees is literally this list, oldest first.
Package validation¶
Weight and all three dimensions must be > 0. A sanity ceiling exists (1000kg, 500cm per
dimension) purely to catch obviously-wrong input - it is not a real courier's weight/size
limit, which this codebase deliberately has no knowledge of (see "Provider abstraction" above).
One active shipment per SellerOrder¶
A SellerOrder may have at most one active (non-Cancelled) shipment at a time, but a new
shipment can be created after a previous one is cancelled. This is enforced at the application
layer (CreateShipmentCommandHandler checks ILogisticsRepository.GetActiveBySellerOrderIdAsync
before creating), not with a database unique index: a plain unique index on SellerOrderId would
wrongly block re-shipment after cancellation, and MySQL has no partial/filtered unique index
available to both the production MySQL runtime and the SQLite provider the test suite runs
against. A genuine simultaneous double-submit for the same SellerOrder is an accepted, documented
race window - contrast with SellerSettlement.SellerOrderId's real unique index, safe there
because a settlement is permanently one-per-order and never re-created.
SellerOrder prerequisite¶
A shipment may only be created once its SellerOrder is ReadyToShip (not merely Packed) -
SellerOrderNotReadyForShipmentException otherwise. This is the same fulfilment pipeline
Seller Order Fulfilment already built; Logistics does not introduce a
second, competing tracking system. A seller can still use the original manual "Ship Order" dialog
(SellerOrderDetail.jsx) instead of creating a Shipment at all - both reach Shipped, but only
the Logistics path produces a Shipment/tracking timeline.
SellerOrder synchronization¶
Two Shipment transitions reach into the existing fulfilment domain, reusing its methods verbatim rather than duplicating any logic:
MarkPickedUp- the earliest point where "a carrier physically has the package" is true - callsSellerOrder.Ship(serviceName, trackingNumber, trackingUrl), movingReadyToShip -> Shipped.MarkDeliveredcallsSellerOrder.Deliver()(Shipped -> Delivered) and thenOrder.RecalculateStatusFromSellerOrders()- the exact same parent-aggregation rule Seller Order Fulfilment already documents.
Both sync calls are guarded by the SellerOrder's own current status (only fired if still
ReadyToShip/Shipped respectively) so a shipment that's somehow out of step with its SellerOrder
never forces an invalid transition - it just skips the sync silently, since Shipment's own
transition methods are already the idempotency gate (a repeat MarkPickedUp/MarkDelivered call
throws before reaching the sync code at all).
Nothing in Logistics ever changes inventory quantities - creating, progressing, or cancelling a
shipment has no effect on SellerInventory. Inventory is committed at checkout and restored only
by SellerOrder.Cancel (see Seller Order Fulfilment); a cancelled shipment
just clears the way for a new one against the same, still-committed, SellerOrder.
Cancellation¶
Shipment.Cancel only succeeds from Draft/ReadyForPickup/PickupScheduled -
ShipmentCannotCancelException from PickedUp onward (a carrier already has the package),
ShipmentAlreadyDeliveredException specifically if Delivered. CancelShipmentCommandHandler
notifies the provider first (ILogisticsProvider.CancelShipmentAsync - a no-op that always
succeeds for both implementations in this slice, since neither holds a real courier booking) then
calls Shipment.Cancel. Cancelling a shipment does not cancel the underlying SellerOrder or
touch inventory - the seller can immediately create a replacement shipment for the same
SellerOrder (see "One active shipment per SellerOrder" above).
Shipping cost model¶
Two separate amounts exist and are never assumed equal anywhere in this codebase:
SellerOrder.ShippingCost- the customer-facing shipping charge allocated to that seller at checkout.Shipment.ShippingCost- the authoritative logistics cost, computed by whicheverILogisticsProviderbooked the shipment.
Marketplace:Logistics:SellerShippingReimbursed (bool, default false when absent) controls
whether settlement generation reimburses the seller for the second amount.
GenerateSettlementCommandHandler (in SettlementFeatures.cs) now looks up any non-cancelled
Shipment for the SellerOrderId being settled: if the flag is true and a shipment exists,
SellerShippingAmount = Shipment.ShippingCost; otherwise SellerShippingAmount = 0 (the platform
keeps the entire customer shipping charge and reimburses nothing). This is evaluated once, at
generation time, and never revisited - a shipment created or cancelled after a settlement already
exists never retroactively changes it, let alone an already-Settled one.
Error codes¶
| Code | HTTP | Meaning |
|---|---|---|
SHIPMENT_NOT_FOUND |
404 | No such shipment |
SHIPMENT_FORBIDDEN |
403 | Not this seller's shipment, and caller isn't Admin |
SHIPMENT_ALREADY_EXISTS |
409 | An active shipment already exists for this seller order |
INVALID_SHIPMENT_TRANSITION |
409 | Wrong current status for the action requested |
INVALID_PACKAGE_DIMENSIONS |
400 | Weight/length/width/height not positive, or over the sanity ceiling |
INVALID_SHIPPING_COST |
400 | Provider returned a non-positive cost |
WAREHOUSE_FORBIDDEN |
403 | The given warehouse does not belong to the seller who owns the order |
SELLER_ORDER_NOT_READY_FOR_SHIPMENT |
409 | SellerOrder is not yet ReadyToShip |
TRACKING_REQUIRED |
400 | (reserved - Shipment never produces a blank tracking number itself) |
SHIPMENT_ALREADY_DELIVERED |
409 | Action attempted after Delivered |
SHIPMENT_CANNOT_CANCEL |
409 | Cancel attempted after pickup |
Never a raw EF/MySQL error - see ExceptionHandlingMiddleware.cs.
A recurring EF Core pitfall this slice hit¶
Appending a new ShipmentTrackingEvent to an already-tracked Shipment.TrackingEvents collection
(i.e. every lifecycle transition after the shipment's initial creation, once loaded fresh in a new
request scope) triggers EF Core's known "misclassifies a new client-keyed child as Modified
instead of Added" behavior, and threw DbUpdateConcurrencyException in every transition handler
until fixed. Since ShipmentTrackingEvent has no update method anywhere (append-only by
construction), any Modified entry of that type is by definition always a misclassified new one
- LogisticsRepository.SaveChangesAsync flips every such entry to Added before saving, and every
Logistics command handler saves through ILogisticsRepository (not IUnitOfWork directly) so this
fix-up always runs.
Future webhook architecture (not implemented)¶
A real courier integration would eventually need an inbound webhook (courier -> platform) to push
tracking updates instead of the seller manually clicking through each transition. That surface is
not built or exposed in this slice - no unauthenticated/unsigned webhook endpoint exists
anywhere in this codebase. When it is built, it should: (1) live behind its own signature
verification (vendor-specific, so it belongs inside that vendor's future ILogisticsProvider
implementation, not in LogisticsController), (2) translate the vendor's payload into the same
MarkPickedUp/MarkInTransit/.../MarkDelivered commands this slice already exposes, so the
domain model and SellerOrder-sync logic need no changes, and (3) never trust a courier-supplied
status blindly enough to skip Shipment's own transition guards.
Frontend¶
- Seller:
/seller/logistics(grid of shipments, status filter, "Create Shipment" dialog that picks aReadyToShipseller order + warehouse + package dimensions) and/seller/logistics/:id(detail: tracking timeline, package/pickup/delivery info, one context-sensitive primary action button per status, cancel while pre-pickup). The original manual "Ship Order" dialog on/seller/orders/:idstill exists unchanged - a seller chooses one path or the other per order. - Admin:
/admin/logistics(list, filterable) and/admin/logistics/:id- read-only operational visibility, matchingAdminOrders' existing restraint (the seller operates their own shipments day to day). - Customer: the order-detail page (
OrderDetailPage.jsx) renders a shipment tracking timeline under each seller's fulfilment card when one exists, with an external "Track shipment" link (target="_blank" rel="noopener noreferrer").
Related pages¶
- Seller Order Fulfilment - the SellerOrder state machine this slice syncs
into via
Ship/Deliver/RecalculateStatusFromSellerOrders - Payments & Seller Settlements - settlement generation's optional shipping-cost reimbursement, read from here
- Marketplace Catalog Model -
Warehouse, whose fields this slice snapshots verbatim - Marketplace Notifications - the OutForDelivery customer notification, and the SellerOrder-sync dedup relationship with the fulfilment slice's own Shipped/Delivered notifications
- Marketplace Returns & Refunds - the reverse-logistics
ReturnShipmentaggregate that mirrors this slice's provider abstraction, never reusing the outboundShipmentit documents here - Application Architecture