A Core dna store is a set of records and an API that moves a purchase from cart to order: products described in every language and priced in every currency the store sells in, a cart, a checkout that becomes an order, invoices, payments, tax and shipping. The site builds the storefront on top: the cart and checkout pages, their scripts, and the customer emails.
L2–L3
Unit 05 · From payment to fulfilment
Stock, fulfilment and the member's order screens
From payment to fulfilmentPage 21 of 31Updated
How stock is recorded per warehouse and kept in step with the system that ships the goods, how fulfilments and shipments follow an order out of the door, which status a customer sees, and how to build the member's order screens.
Who this is for. Developers connecting a store to its stock and dispatch systems, and building the order pages of the member area. After this page you can keep stock figures right, show availability, track an order's journey, and override the two order screens.
Stock is a ledger per warehouse
Stock is recorded in four records:
Record
What it holds
Warehouse
A place stock is kept, with a unique code and an address.
Inventory transaction
One movement of stock: the SKU, the warehouse, the quantities moved and the reason. It can belong to a batch, and can carry an expiry, for a reservation held before payment. A transaction is final once written.
Stock
One product's or variant's quantities in one warehouse: available, reserved, in transit and on hand. It is the running total of that product's transactions in that warehouse, kept up to date by the platform as transactions are written.
Batch
A lot of goods, with a name, an expiry date and a received date, for lot and expiry tracking.
The four quantities let the store say where every unit is: available to sell, reserved for an order that has not left yet, in transit to a warehouse or a customer, and on hand on the shelf. A product and each variant also carry their available quantity summed across the account's warehouses, which templates read with getAvailableQuantity().
Keeping stock in step
Stock changes when a transaction is written. There are two ways to write one:
The product editor. Staff enter the four quantities per warehouse as they are now; on save, the platform writes one transaction per warehouse holding the difference. A form opened before someone else changed the stock is refused, so two people never overwrite each other's counts.
The Inventory API.POST /cdna-api/inventory/transaction records one movement for a SKU in a warehouse: the quantity moved in each bucket and a reason (Record a transaction).
The recommended set-up gives the numbers one owner: the system that ships the goods, such as a warehouse or ERP system, writes a transaction through the API for each movement it makes. Goods received add to on hand and available; an order picked moves units from available to reserved; a parcel handed to the courier moves them out; a return put back on the shelf adds them again. When to reserve and when to take units out of stock is that system's rule; tell it about each paid order with a job on the order (Entity events). The store then reads stock and never has to calculate it.
A transaction that makes a product available again fires the back-in-stock event, which a job turns into emails to the people who asked (Back-in-stock requests).
Selling limited stock
A store that sells limited stock shows availability on the product page and offers the add button only while something is available:
{% set product = productDescription.getProduct() %}
{% if product.getAvailableQuantity() > 0 %}
<p class="stock">In stock</p>
<button type="submit">Add to cart</button>
{% else %}
<p class="stock">Out of stock</p>
{% include 'products/includes/back-in-stock-form.html' %}
{% endif %}
The include is the store's own back-in-stock form. For a product with variants, read each variant's own getAvailableQuantity() and offer only the options that have stock (The product page and its variants). Where the last units of something can sell twice at the same moment, as in a flash sale, the system that ships the goods has the final word: it confirms each paid order against the shelf, and an item it cannot supply is refunded and the customer told (Refunds).
Fulfilments and shipments
A fulfilment is a set of an order's lines that leave together. It is created at checkout when shipping is chosen, one for all available items from the built-in delivery and pickup options (Creating shipping and showing the rates), and moves to the order at the conversion. It holds:
its items: each order line it covers, with a quantity (getFulfillmentItems());
a delivery address for a delivery, or a warehouse for a pickup (getDeliveryAddress(), getWarehouse());
the shipping rate the shopper chose (getSelectedShippingRate());
its status, and its shipments.
Staff move a fulfilment through its statuses in the admin, on the order's fulfilments:
Value
Label
pending
Pending
ready
Ready
in_transit
In Transit
delivered
Delivered
cancelled
Cancelled
A shipment is one parcel on its way. It belongs to a fulfilment and carries the courier's trackingCode and trackingUrl, an externalId and the amount charged. A fulfilment whose items leave in more than one parcel has a shipment for each. An order can also have more than one fulfilment, each with its own status: that is a partial fulfilment, where some lines ship before others.
The status a customer sees
The order's fulfilment status sums up its fulfilments, and is worked out again every time the order is saved. A cancelled fulfilment is left out of the count. getFulfillmentStatus() returns the value and getFulfillmentStatusDisplay() the label:
Value
Label
When
none
Not Fulfilled
No fulfilments, or all of them cancelled
pending
Pending Fulfillment
Every fulfilment is pending
partial
Partially Fulfilled
Some fulfilments are pending and others have moved on
fulfilled
Fulfilled
None is pending: each is ready, in transit or delivered, and not all are delivered
completed
Fulfillment Complete
Every fulfilment is delivered
Show the customer the order's fulfilment status as the summary, and beside the items each fulfilment covers, its own status and tracking links.
The member's order screens
The member area has two order screens, and each shows only the signed-in member's own orders:
Screen
Template
Receives
The member's orders, a page at a time
orders/orders/index.html
orders
One order
orders/orders/show.html
order
The framework ships starter versions of both, which extend users/frontend/includes/account.html. Override them by creating the same paths in the site's templates/ folder, and print every amount with the money getters and format(), which format in the order's own currency. Link a row to its order with $_url->getPath('MyAccount', 'orderDetails', ['id' => $order->getId()]), which builds the member area's path for that order.
Template codetemplates/orders/orders/index.html
<table>
{foreach $orders as $order}
{$ordered = $order->getOrdered()}
<tr>
<td><a href="{$_url->getPath('MyAccount', 'orderDetails', ['id' => $order->getId()])}">{$order->getNumber()|escape}</a></td>
<td>{if $ordered}{$ordered->format('j M Y')}{/if}</td>
<td>{$order->getFinancialStatusDisplay()}</td>
<td>{$order->getFulfillmentStatusDisplay()}</td>
<td>{$order->getTotalAsMoney()->format()}</td>
</tr>
{foreachelse}
<tr><td colspan="5">You have no orders yet.</td></tr>
{/foreach}
</table>
<table>
{% for order in orders %}
<tr>
<td><a href="{{ _url.getPath('MyAccount', 'orderDetails', {'id': order.getId()}) }}">{{ order.getNumber()|e }}</a></td>
<td>{{ order.getOrdered() ? order.getOrdered()|date('j M Y') }}</td>
<td>{{ order.getFinancialStatusDisplay() }}</td>
<td>{{ order.getFulfillmentStatusDisplay() }}</td>
<td>{{ order.getTotalAsMoney().format() }}</td>
</tr>
{% else %}
<tr><td colspan="5">You have no orders yet.</td></tr>
{% endfor %}
</table>
On the order screen, each line's stored name is in the client's default language. On a multilingual site, take the name from the line's product description in the visitor's language, keeping the stored name as the fallback, as Reading the right description does for product cards. Fulfilments follow the lines, with a tracking link for each shipment that has one:
{% set language = _query.findOneBy('Common\\Entities\\Language', {'code': _request.getLanguage()}) %}
<h1>Order {{ order.getNumber()|e }}</h1>
<p>{{ order.getFinancialStatusDisplay() }} · {{ order.getFulfillmentStatusDisplay() }}</p>
<table>
{% for item in order.getItems() %}
{% set name = item.getName() %}
{% set product = item.getProduct() %}
{% set description = language and product ? product.getDescriptionForLanguage(language) : null %}
{% if description %}{% set name = description.getName() %}{% endif %}
<tr><td>{{ name|e }} × {{ item.getQuantity() }}</td><td>{{ item.getTotalAsMoney().format() }}</td></tr>
{% endfor %}
<tr><td>Shipping</td><td>{{ order.getShippingTotalAsMoney().format() }}</td></tr>
<tr><td>Tax</td><td>{{ order.getTaxTotalAsMoney().format() }}</td></tr>
<tr><td>Total</td><td>{{ order.getTotalAsMoney().format() }}</td></tr>
</table>
{% for fulfillment in order.getFulfillments() %}
<h2>{{ fulfillment.getStatusDisplay() }}</h2>
{% for shipment in fulfillment.getShipments() %}
{% if shipment.getTrackingUrl() %}
<p><a href="{{ shipment.getTrackingUrl()|e }}">Track parcel {{ shipment.getTrackingCode()|e }}</a></p>
{% endif %}
{% endfor %}
{% endfor %}
The order's refunds and the total refunded so far read the same way (The adjustment is meant to go negative). How the member area routes these screens and checks the signed-in member is Guide 7's subject.