Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/runtime-regressions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ name: Runtime regressions
on: [push, pull_request]
jobs:
runtime:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
php: ['8.4', '8.5']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Checkout sCommerce
uses: actions/checkout@v6
- name: Checkout Evolution CMS
uses: actions/checkout@v6
with:
repository: evolution-cms/evolution
ref: 3.6.x
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ of Evolution CMS for seamless and efficient **online commerce**.
- [x] Stock Integration (external stock provider, stock write API, optional stock reduction on orders).
- [x] sCommerce.ResolveProductStock.
- [x] sCommerce.ProductStockChanged.
- [x] Split Cart Lines (one product sold from stock lots at different prices, e.g. older excise lots first or perishables close to expiry at a markdown, with a stock allocation per order line).
- [x] sCommerce.SplitCartLine.
- [x] JavaScript Callbacks.
- [x] sCommerce.onAddedToCart.
- [x] sCommerce.onRemovedFromCart.
Expand Down
2 changes: 1 addition & 1 deletion docs/en/developers/deliveries.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class CustomDelivery extends BaseDeliveryMethod

public function calculateCost(array $order): float
{
return 50.00; // Fixed cost or custom calculation
return 50.00; // In getCostCurrency(): the currency of the method's settings row
}

public function defineFields(): array
Expand Down
96 changes: 95 additions & 1 deletion docs/en/developers/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ Settings (**Settings → Product**):

- **Prices without VAT** (`product.vat_display_on`, off by default) switches the feature on. While it is off, the settings below are hidden, prices are always shown with VAT, the display keys equal the charged prices and `scommerce/vat-display` answers `404`;
- **VAT rate, %** (`product.vat_rate`, default `0`): the rate included in product prices. With `0`, the net price equals the gross price;
- **Prices shown** (`product.vat_display`, `gross` or `net`, default `gross`): the default for visitors who have not chosen.
- **Storefront prices by default** (`product.vat_display`, `gross` or `net`, default `gross`): the default for visitors who have not chosen, e.g. `net` for a B2B shop. It affects only the storefront;
- **Without VAT** in **Presentation of the list of products** (`products.show_field_price_net`, on by default): a manager product list column with the price without VAT and the included retail excise (`sProduct::priceNetTo()`), shown while prices without VAT are on.

Defaults of products sold by measure and the rounding to the step per unit have their own **Measured quantities** block on the same settings page.

The net price is `round(gross / (1 + rate / 100), 2)`. Templates read these values:

Expand Down Expand Up @@ -193,6 +196,23 @@ integer conversion, single-product totals and deposits follow successful validat
the actual product or cart lines; partial forms without products skip that rule.
The package contains no project-specific minimum attribute or policy class.

Since 1.5.0 the storefront is checked before an order is saved:

- `processOrder()` re-prices the lines it kept since `initCheckout()` with current prices, publication,
stock and currency. When anything differs it stores the current lines and fails with
`error_code` `cart_changed` and the new `products` and `cost`; the customer confirms the order again.
Lines above stock fail with `out_of_stock`, in quick orders too.
- The delivery cost is recalculated on every `setOrderData()` and again in `processOrder()`, and
converted from the method's cost currency into the order currency. A failed attempt leaves the
checkout data as it was, so a retry never adds the delivery twice.
- Unpublished products cannot be added to the cart or ordered. Quick orders without a `productId`
order the cart; any other value than a whole number is an unknown product. Piece quantities stop at
1,000,000, and a negative price from a listener never lowers a total.
- The `scommerce/process-order` and `scommerce/quick-order` routes return the order as
`sOrder::toCustomerArray()` (no manager notes, history or `meta`) and allow `orders.rate_limit`
orders per IP address in 10 minutes (default 10, 0 = no limit, **Settings → Cart and orders**);
above it they answer `429` with `error_code` `rate_limited`.

These three events use the exact names documented on this page, without the `evolution.` prefix. The previous price-event names `evolution.sCommerceResolveProductPriceMode` and `evolution.sCommerceResolveProductPrice` are no longer dispatched; update project listeners and sPricing together with sCommerce.

### sCommerce.ResolveProductPriceMode
Expand Down Expand Up @@ -568,6 +588,80 @@ Since 1.5.0, when a manager changes the products of an order whose stock sCommer

`sCommerce::supportsIntegration('stock-provider')` reports support.

## Split cart lines

### sCommerce.SplitCartLine

Since 1.5.0, a listener can sell one cart entry (a product with an option and a quantity) as several order lines with different prices. sCommerce itself has no stock lots; this event lets a lot package or an ERP adapter apply its own rules. Typical cases:

- Excise goods with a price fixed per lot: the units of the older lot are sold at its price before the next lot opens.
- Perishables: units of a lot close to its expiry date and time are sold first at a markdown price, the rest of the same entry at the regular price.
- Goods that must come from one batch, such as tiles of one shade: the listener names the batch on the line.

The cart, the checkout and the single-product quick order dispatch the event for every entry after its price is resolved. The payload contains `product`, `optionId`, `quantity` (base units for products sold by measure), `currency` and `pricing` (the resolved price of the entry, see [sCommerce.ResolveProductPrice](#scommerceresolveproductprice)). The first listener that returns an array decides; return `null` to leave the entry as one line.

The response is a list of parts whose quantities add up to the entry quantity:

| Key | Required | Meaning |
|---|---|---|
| `key` | yes | Unique within the entry: 1 to 40 letters, digits, `.`, `_` or `-`, for example `lot-11` |
| `quantity` | yes | Positive integer |
| `priceAsFloat` | no | Unit price in `currency`; without it the part has the price of the entry |
| `oldPriceAsFloat` | no | Crossed-out price of a markdown; only together with `priceAsFloat`, default none |
| `label` | no | Text up to 255 characters for the customer and the manager, for example `Best before 27.10 18:00` |
| `allocation` | no | JSON-encodable array stored on the order line as it is, for example the lot ids and quantities |

Any other response, such as quantities with a different sum, a duplicate key or a negative price, throws `UnexpectedValueException`, so a listener error is never sold at a wrong price.

```php
use Illuminate\Support\Facades\Event;
use Seiger\sCommerce\Cart\CartLineParts;

Event::listen(CartLineParts::EVENT, function (array $payload) {
$lots = MyLots::sellable($payload['product']->id); // oldest first, each with id, quantity, price, markdown
if ($lots === []) {
return null;
}
$parts = [];
$left = $payload['quantity'];
foreach ($lots as $lot) {
$units = min($left, $lot->quantity);
if ($units > 0) {
$parts[] = array_filter([
'key' => 'lot-' . $lot->id,
'quantity' => $units,
'priceAsFloat' => $lot->markdown ?? $lot->price,
'oldPriceAsFloat' => $lot->markdown !== null ? $payload['pricing']['priceAsFloat'] : null,
'label' => $lot->markdown !== null ? 'Best before ' . $lot->expiresAt->format('d.m H:i') : null,
'allocation' => ['lots' => [['id' => $lot->id, 'quantity' => $units]]],
], static fn ($value) => $value !== null);
$left -= $units;
}
}

return $parts;
});
```

Each part becomes one cart and order line:

- Its `line_key` is `{productId}:{optionId}:{key}`, for example `5:0:lot-11`.
- Its price, sum, VAT and deposit keys are calculated for the part. Declared container exchanges fill the parts in order.
- It carries `part_key`, `part_label` (when given) and `allocation` (when given). Order emails, the manager order screen and the print form show `part_label` under the product title.
- In the cart only, `cart_quantity` is the quantity of the whole entry. A quantity control of a split line must send this entry quantity, because `sCart::addProduct()` sets the quantity of the entry, not of one part. `cart_quantity` is not stored on orders.

The checkout prices its lines again before saving the order. When the split changed meanwhile, for example a lot sold out, the line keys or prices differ, and the customer gets `cart_changed` with the new lines and total.

The listener also has to make the rest of the lot rules consistent:

- **Stock.** Make `sCommerce.ResolveProductStock` or a stock provider return what can be sold.
- **Catalog price.** Make `sCommerce.ResolveProductPrice` return the price of the first unit, for product cards.
- **Committing stock.** Lot stock is committed by a `StockProviderInterface`, which reads `allocation` from the order lines. sCommerce does not interpret allocations. Its local stock reduction counts the parts of a product together, as before.

Part keys stay on the server: when a manager saves an order, lines keep the `part_key`, `part_label` and `allocation` stored under their `line_key`, and lines added in the manager have none. A manager who changes the quantity of a split line changes the line, not its allocation; the stock provider sees `sCommerce.OrderUpdated`. Repeated orders keep the stored prices and labels but drop `allocation`.

`sCommerce::supportsIntegration('line-parts')` reports support.

## Additional manager and frontend events

Manager events use the evolution. prefix with Laravel Event listeners. Pricing events use their literal sCommerce.* names without that prefix. Frontend callbacks use the names below; do not infer that every callback has the same response fields.
Expand Down
3 changes: 2 additions & 1 deletion docs/en/developers/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ Since 1.5.0, every new order line (storefront cart, quick order, manager editor

| Key | Type | Meaning |
|---|---|---|
| `line_key` | string | `"{productId}:{optionId}"`, for example `"5:3"` |
| `line_key` | string | `"{productId}:{optionId}"`, for example `"5:3"`; lines of a split cart entry add the part key: `"5:0:lot-11"` |
| `product_id` | int | product id (same as `id`) |
| `option_id` | int | cart option id; `0` = none |
| `sku` | string | effective SKU; equals `product_sku` unless a `sCommerce.ResolveLineIdentity` listener sets a variant SKU |
Expand All @@ -304,6 +304,7 @@ Since 1.5.0, every new order line (storefront cart, quick order, manager editor
| `retail_excise_rate`, `retail_excise_amount` | float | only on lines with a retail excise: its rate and the amount included in the line total, for the receipt |
| `deposit_unit`, `exchange_quantity`, `deposit_quantity`, `deposit_amount`, `deposit_returnable`, `deposit_terms`, `deposit_returned_quantity` | mixed | only on lines with a returnable container deposit, see [Returnable container deposits](payments/index.md#returnable-container-deposits) |
| `measure`, `base_unit`, `price_unit`, `price_per`, `factor`, `quantity_*`, `quantity_label`, `unit_price_label`, `line_weight`, `actual_quantity*` | mixed | only on lines of products sold by weight, length, volume or count; `quantity` is then in base units (grams, millimetres, millilitres, items). See [Measured quantities](measured-quantities.md#order-lines) |
| `part_key`, `part_label`, `allocation` | string, string, array | only on lines of a cart entry split by a `sCommerce.SplitCartLine` listener, for example by stock lots: the part key, its label and the listener's allocation. See [Split cart lines](events.md#split-cart-lines) |

Tax listeners may also add `tax_code`, `unit_price_gross`, `tax_amount` and `prices_include_tax`. See [Events](events.md#order-lines-fulfilment-and-taxes).

Expand Down
2 changes: 1 addition & 1 deletion docs/en/developers/payments/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,7 @@ Since 1.5.0, a product can carry a returnable container deposit, for example a w

### Checkout and exchange

The customer declares how many empty containers they hand over. Post `productId`, `optionId` and `quantity` to `scommerce/container-exchange` (route `sCommerce.containerExchange`) or call `sCart::setContainerExchange($productId, $optionId, $count)`; quick orders accept `exchange`. The count is capped at the line quantity. sCommerce keeps no balance of containers per customer: when the courier finds a different number, the manager changes **Exchanged containers** on the order line, and the deposit and the order cost are recalculated.
The customer declares how many empty containers they hand over. Such a declaration waives the deposit before anyone has seen the containers, so it needs **Customers declare exchanged containers** (`product.container_exchange_on`, off by default); otherwise the route answers `404`, `setContainerExchange()` fails and declared exchanges are ignored. Post `productId`, `optionId` and `quantity` to `scommerce/container-exchange` (route `sCommerce.containerExchange`) or call `sCart::setContainerExchange($productId, $optionId, $count)`; quick orders accept `exchange`. The count is capped at the line quantity. sCommerce keeps no balance of containers per customer: when the courier finds a different number, the manager changes **Exchanged containers** on the order line, and the deposit and the order cost are recalculated.

Cart lines and order lines with a deposit store:

Expand Down
2 changes: 1 addition & 1 deletion docs/ru/developers/deliveries.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class CustomDelivery extends BaseDeliveryMethod

public function calculateCost(array $order): float
{
return 50.00; // Фиксированная стоимость или пользовательский расчет
return 50.00; // В валюте getCostCurrency(): валюта строки настроек способа
}

public function defineFields(): array
Expand Down
Loading
Loading