91 lines
5.4 KiB
Markdown
91 lines
5.4 KiB
Markdown
# 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<ShippingItem>`) | 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) |
|