# Pre-Order Workflow > Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `../README.md` for the docs index, `../../README.md` for project overview. > For entity definitions (`PreOrder`, `PreOrderItem`, `PreOrderStatus`, `PreOrderItemStatus`) see `../SCHEMA.md` (authoritative TOON schema). > For the entity-hierarchy overview see `../DOMAIN_MODEL.md`; for the inbound shipping flow that triggers conversion see `../MEASUREMENT.md` (Workflow 1). > Known issues / planned work: [`PREORDER_ISSUES.md`](PREORDER_ISSUES.md) / [`PREORDER_TODO.md`](PREORDER_TODO.md). Customers place **pre-orders** for products that are not yet physically in stock. When a supplier shipping document confirms incoming stock, pending pre-orders are allocated against that stock and converted into real nopCommerce orders. This is the fourth domain workflow alongside the three measurement workflows (shipping, order, stocktaking). ## Entity Hierarchy ``` PreOrder (fbPreOrder) ← customer advance-order header └─ PreOrderItem (fbPreOrderItem) ← requested product line [1:N] PreOrder.OrderId ─▶ Order ← real nopCommerce order, created on first fulfilment [N:1, nullable] ``` Both entities live in `FruitBank.Common/Entities/` (shared with FruitBankHybridApp), inherit `MgEntityBase`, and use LinqToDB. ## Status Lifecycle ``` PreOrderStatus: Pending(0) → Confirmed(10) / PartiallyFulfilled(20) / Cancelled(30) PreOrderItemStatus: Pending(0) → Fulfilled(10) / PartiallyFulfilled(20) / Dropped(30) ``` Header status is **derived from item states** by `PreOrderDbContext.RefreshPreOrderStatusAsync`: | Condition | PreOrder.Status | |---|---| | all items `Fulfilled` | `Confirmed` | | any `Dropped`/`PartiallyFulfilled` AND none `Pending` | `PartiallyFulfilled` | | otherwise | `Pending` | | customer/admin cancel | `Cancelled` (set explicitly; active items → `Dropped`) | ## Workflow ### 1. Placement `PreOrderController.PlacePreOrder` (public), also `OrderController` and `PreOrderAdminController` (admin): - Available products are filtered by the per-product pre-order window (`PreOrderWindowStart` / `PreOrderWindowEnd` product GenericAttributes). - Unit price is captured at placement into `PreOrderItem.UnitPriceInclTax` (price lock). - `PreOrderDbContext.InsertPreOrderAsync` saves the header (`Status = Pending`) and items (`FulfilledQuantity = 0`, `Status = Pending`). ### 2. Conversion (on incoming stock) `PreOrderConversionService.ConvertPreOrdersForProductsAsync(productIds, shippingDocumentId)`: 1. **Expiry sweep** first (see §4). 2. Fetch `Pending`/`PartiallyFulfilled` items for the products (FCFS-ordered by `PreOrderId`). 3. **Conversion-window filter** — only preorders whose `DateOfReceipt` is within `PreOrderConversionWindowDays` (4 days) are eligible; farther-out deliveries wait for a later document. 4. Build the incoming-quantity pool (`AvailableQuantity` minus quantities already committed to preorders). 5. Allocate FCFS: per item `fulfill = min(RequestedQuantity − FulfilledQuantity, available)`; set item status `Fulfilled` / `PartiallyFulfilled`. 6. **First fulfilment** for a preorder → `CreateOrderAsync` (creates the nopCommerce `Order`, saves `PreOrder.OrderId`). **Subsequent documents** → `AppendItemsToOrderAsync` (adds the newly-fulfilled items to the same order). 7. Dropped items are recorded in an order note (never become order items). ### 3. Triggers | Source | When | |---|---| | `FruitBankEventConsumer` (`EntityInserted/UpdatedEvent`) | shipping item with a matched product + quantity (background `Task.Run`) | | `FruitBankDataController.UpdateShippingItem` | shipping-item quantity increase | | `FileManagerController` | shipping-document processing (batch over the document's products) | | `OrderController` / `PreOrderAdminController` | manual / immediate-from-stock | `IncomingQuantity` is kept in sync by `FruitBankDataController.AddShippingItem` / `UpdateShippingItem` → `PreOrderConversionService.SyncIncomingQuantityAsync`. Product replacement on a shipping item is handled by `ReplaceShippingItemProductAsync` (`PreOrderConversionService.Replace.cs`), which moves stock/incoming, swaps order items, repoints preorder items, and re-triggers conversion for the new product. ### 4. Expiry `SweepExpiredPreordersAsync` runs at the start of every conversion run: for preorders past `DateOfReceipt` still holding `Pending`/`PartiallyFulfilled` items, any still-`Pending` item becomes `Dropped` and the header status is refreshed. Already-fulfilled quantities stay (they are already on a real order). ## Pricing | Product | Order-item price on conversion | |---|---| | Non-measurable | `UnitPriceInclTax × FulfilledQuantity` (locked at placement) | | Measurable | `0` at conversion — weighed afterwards (see `../MEASUREMENT.md` Workflow 2) | ## Key Source | Type | Location | |---|---| | `PreOrderConversionService` (+ `.Replace` partial) | `Services/` | | `PreOrderDbContext` (`InsertPreOrderAsync`, `RefreshPreOrderStatusAsync`, `CancelPreOrderAsync`) | `Domains/DataLayer/` | | `PreOrderDbTable` / `PreOrderItemDbTable` (+ `IPreOrderDbSet` / `IPreOrderItemDbSet`) | `Domains/DataLayer/` | | `PreOrderController` / `CustomerPreOrderController` | `Controllers/` | | `PreOrderAdminController` / `PreOrderAvailabilityController` | `Areas/Admin/Controllers/` | | `PreOrder` / `PreOrderItem` entities | `FruitBank.Common/Entities/` (FruitBankHybridApp solution) |