diff --git a/.github/workflows/runtime-regressions.yml b/.github/workflows/runtime-regressions.yml index 2fc70e5..52cf218 100644 --- a/.github/workflows/runtime-regressions.yml +++ b/.github/workflows/runtime-regressions.yml @@ -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 diff --git a/README.md b/README.md index 7ac6aad..20275dd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/en/developers/deliveries.md b/docs/en/developers/deliveries.md index dcc62b1..0bf4d32 100644 --- a/docs/en/developers/deliveries.md +++ b/docs/en/developers/deliveries.md @@ -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 diff --git a/docs/en/developers/events.md b/docs/en/developers/events.md index a48e5c1..51a7895 100644 --- a/docs/en/developers/events.md +++ b/docs/en/developers/events.md @@ -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: @@ -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 @@ -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. diff --git a/docs/en/developers/integration.md b/docs/en/developers/integration.md index 061fb8a..543dde8 100644 --- a/docs/en/developers/integration.md +++ b/docs/en/developers/integration.md @@ -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 | @@ -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). diff --git a/docs/en/developers/payments/index.md b/docs/en/developers/payments/index.md index 76af651..985fe38 100644 --- a/docs/en/developers/payments/index.md +++ b/docs/en/developers/payments/index.md @@ -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: diff --git a/docs/ru/developers/deliveries.md b/docs/ru/developers/deliveries.md index 014372a..608f1d4 100644 --- a/docs/ru/developers/deliveries.md +++ b/docs/ru/developers/deliveries.md @@ -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 diff --git a/docs/ru/developers/events.md b/docs/ru/developers/events.md index c1f7d54..0a79164 100644 --- a/docs/ru/developers/events.md +++ b/docs/ru/developers/events.md @@ -110,7 +110,10 @@ sCart::clearPriceMode(); - **Цены без НДС** (`product.vat_display_on`, по умолчанию выключено) включает функцию. Пока она выключена, настройки ниже скрыты, цены всегда показываются с НДС, ключи для отображения равны ценам к оплате, а `scommerce/vat-display` отвечает `404`; - **Ставка НДС, %** (`product.vat_rate`, по умолчанию `0`): ставка, включённая в цены товаров. При `0` цена нетто равна цене брутто; -- **Отображение цен** (`product.vat_display`, `gross` или `net`, по умолчанию `gross`): значение по умолчанию для посетителей, которые не сделали выбор. +- **Цены на витрине по умолчанию** (`product.vat_display`, `gross` или `net`, по умолчанию `gross`): значение по умолчанию для посетителей, которые не сделали выбор, например `net` для B2B-магазина. Влияет только на витрину; +- **Без НДС** в **Представление списка товаров** (`products.show_field_price_net`, по умолчанию включено): колонка списка товаров в менеджере с ценой без НДС и включённым в неё розничным акцизом (`sProduct::priceNetTo()`), показывается, пока включены цены без НДС. + +Значения по умолчанию для товаров, продаваемых по мере, и округление до шага за единицу настраиваются в отдельном блоке **Измеряемые количества** на той же странице настроек. Цена нетто рассчитывается как `round(gross / (1 + rate / 100), 2)`. Шаблоны читают такие значения: @@ -220,6 +223,23 @@ Event::listen('sCommerce.CheckoutValidationRules', function (array $params): voi корзина. Частичная форма без товаров не запускает правило products. Атрибута минимума и отдельного класса политики проекта в пакете нет. +Начиная с 1.5.0 витрина проверяется перед сохранением заказа: + +- `processOrder()` заново рассчитывает позиции, сохранённые с момента `initCheckout()`, по текущим ценам, + публикации, остаткам и валюте. Если что-то отличается, он сохраняет текущие позиции и завершается ошибкой + с `error_code` `cart_changed` и новыми `products` и `cost`; покупатель подтверждает заказ ещё раз. + Позиции сверх остатка завершаются ошибкой `out_of_stock`, в том числе в быстрых заказах. +- Стоимость доставки пересчитывается при каждом `setOrderData()` и ещё раз в `processOrder()` и + конвертируется из валюты стоимости способа в валюту заказа. Неудачная попытка оставляет данные + оформления без изменений, поэтому повторная попытка никогда не добавляет доставку дважды. +- Неопубликованные товары нельзя добавить в корзину или заказать. Быстрые заказы без `productId` + оформляют корзину; любое значение, кроме целого числа, считается неизвестным товаром. Штучное количество + ограничено 1 000 000, а отрицательная цена от слушателя никогда не уменьшает сумму. +- Маршруты `scommerce/process-order` и `scommerce/quick-order` возвращают заказ как + `sOrder::toCustomerArray()` (без заметок менеджера, истории и `meta`) и допускают `orders.rate_limit` + заказов с одного IP-адреса за 10 минут (по умолчанию 10, 0 = без ограничения, **Настройки → Корзина и заказы**); + сверх этого они отвечают `429` с `error_code` `rate_limited`. + Для этих трёх событий используйте точные имена с этой страницы, без префикса `evolution.`. Предыдущие ценовые события `evolution.sCommerceResolveProductPriceMode` и `evolution.sCommerceResolveProductPrice` больше не вызываются; обновляйте слушатели проекта и sPricing вместе с sCommerce. ### sCommerce.ResolveProductPriceMode @@ -662,6 +682,80 @@ Payload содержит `event`, `productId`, `modificationId` (всегда `0 `sCommerce::supportsIntegration('stock-provider')` сообщает о поддержке. +## Разделение строк корзины + +### sCommerce.SplitCartLine + +Начиная с 1.5.0, слушатель может продать одну позицию корзины (товар с опцией и количеством) несколькими строками заказа с разными ценами. В самом sCommerce нет партий остатков; это событие позволяет пакету партий или адаптеру ERP применять собственные правила. Типичные случаи: + +- Подакцизные товары с ценой, зафиксированной для партии: единицы более старой партии продаются по её цене, прежде чем открывается следующая партия. +- Скоропортящиеся товары: единицы партии, у которой близки дата и время окончания срока годности, продаются первыми по уценённой цене, остальные единицы той же позиции — по обычной цене. +- Товары, которые должны быть из одной партии, например плитка одного тона: слушатель указывает партию в строке. + +Корзина, checkout и быстрый заказ одного товара вызывают событие для каждой позиции после определения её цены. Payload содержит `product`, `optionId`, `quantity` (базовые единицы для товаров, продаваемых по мере), `currency` и `pricing` (определённая цена позиции, см. [sCommerce.ResolveProductPrice](#scommerceresolveproductprice)). Решает первый слушатель, который вернул массив; верните `null`, чтобы оставить позицию одной строкой. + +Ответ — это список частей, количества которых в сумме дают количество позиции: + +| Ключ | Обязательный | Значение | +|---|---|---| +| `key` | да | Уникален в пределах позиции: от 1 до 40 букв, цифр, `.`, `_` или `-`, например `lot-11` | +| `quantity` | да | Положительное целое число | +| `priceAsFloat` | нет | Цена за единицу в `currency`; без неё часть получает цену позиции | +| `oldPriceAsFloat` | нет | Зачёркнутая цена уценки; только вместе с `priceAsFloat`, по умолчанию отсутствует | +| `label` | нет | Текст до 255 символов для покупателя и менеджера, например `Best before 27.10 18:00` | +| `allocation` | нет | Массив, кодируемый в JSON, который сохраняется в строке заказа как есть, например id партий и количества | + +Любой другой ответ, например количества с другой суммой, повторяющийся ключ или отрицательная цена, выбрасывает `UnexpectedValueException`, поэтому ошибка слушателя никогда не приводит к продаже по неверной цене. + +```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; +}); +``` + +Каждая часть становится одной строкой корзины и заказа: + +- Её `line_key` — `{productId}:{optionId}:{key}`, например `5:0:lot-11`. +- Её цена, сумма, НДС и ключи залога рассчитываются для части. Заявленные обмены тары заполняют части по порядку. +- Она содержит `part_key`, `part_label` (если задан) и `allocation` (если задан). Письма о заказе, экран заказа в менеджере и форма печати показывают `part_label` под названием товара. +- Только в корзине `cart_quantity` — это количество всей позиции. Элемент изменения количества разделённой строки должен отправлять это количество позиции, потому что `sCart::addProduct()` задаёт количество позиции, а не одной части. `cart_quantity` не сохраняется в заказах. + +Checkout повторно рассчитывает цены своих строк перед сохранением заказа. Если разделение тем временем изменилось, например партия распродана, ключи строк или цены отличаются, и покупатель получает `cart_changed` с новыми строками и итогом. + +Слушатель также должен согласовать остальные правила партий: + +- **Остаток.** Сделайте так, чтобы `sCommerce.ResolveProductStock` или провайдер остатков возвращал то, что можно продать. +- **Цена в каталоге.** Сделайте так, чтобы `sCommerce.ResolveProductPrice` возвращал цену первой единицы для карточек товаров. +- **Списание остатка.** Остаток партий списывает `StockProviderInterface`, который читает `allocation` из строк заказа. sCommerce не интерпретирует распределения. Его локальное списание остатка, как и раньше, учитывает части одного товара вместе. + +Ключи частей остаются на сервере: когда менеджер сохраняет заказ, строки сохраняют `part_key`, `part_label` и `allocation`, записанные под их `line_key`, а у строк, добавленных в менеджере, их нет. Менеджер, который меняет количество разделённой строки, меняет строку, а не её распределение; провайдер остатков получает `sCommerce.OrderUpdated`. Повторные заказы сохраняют записанные цены и метки, но отбрасывают `allocation`. + +`sCommerce::supportsIntegration('line-parts')` сообщает о поддержке. + ## Дополнительные события менеджера и frontend События менеджера используют префикс evolution. События цен имеют точные имена sCommerce.* без префикса. Структура данных frontend callbacks зависит от операции. diff --git a/docs/ru/developers/integration.md b/docs/ru/developers/integration.md index 01723a2..80dffa0 100644 --- a/docs/ru/developers/integration.md +++ b/docs/ru/developers/integration.md @@ -291,7 +291,7 @@ class SCommerceProductController extends BaseController | Ключ | Тип | Значение | |---|---|---| -| `line_key` | string | `"{productId}:{optionId}"`, например `"5:3"` | +| `line_key` | string | `"{productId}:{optionId}"`, например `"5:3"`; строки разделённой позиции корзины добавляют ключ части: `"5:0:lot-11"` | | `product_id` | int | id товара (совпадает с `id`) | | `option_id` | int | id опции корзины; `0` — опции нет | | `sku` | string | фактический SKU; равен `product_sku`, если слушатель `sCommerce.ResolveLineIdentity` не задал SKU варианта | @@ -303,6 +303,7 @@ class SCommerceProductController extends BaseController | `retail_excise_rate`, `retail_excise_amount` | float | только в строках с розничным акцизом: его ставка и сумма, включённая в сумму строки, для чека | | `deposit_unit`, `exchange_quantity`, `deposit_quantity`, `deposit_amount`, `deposit_returnable`, `deposit_terms`, `deposit_returned_quantity` | mixed | только в строках с залогом за возвратную тару, см. [Залог за возвратную тару](payments/index.md#залог-за-возвратную-тару) | | `measure`, `base_unit`, `price_unit`, `price_per`, `factor`, `quantity_*`, `quantity_label`, `unit_price_label`, `line_weight`, `actual_quantity*` | mixed | только в строках товаров, продаваемых по весу, длине, объёму или количеству; тогда `quantity` указывается в базовых единицах (граммы, миллиметры, миллилитры, штуки). См. [Измеряемые количества](measured-quantities.md#строки-заказа) | +| `part_key`, `part_label`, `allocation` | string, string, array | только в строках позиции корзины, разделённой слушателем `sCommerce.SplitCartLine`, например по партиям остатков: ключ части, её метка и распределение слушателя. См. [Разделение строк корзины](events.md#разделение-строк-корзины) | Налоговые слушатели также могут добавлять `tax_code`, `unit_price_gross`, `tax_amount` и `prices_include_tax`. См. [События](events.md#строки-заказа-исполнение-и-налоги). diff --git a/docs/ru/developers/payments/index.md b/docs/ru/developers/payments/index.md index 1d64aee..cbbf4be 100644 --- a/docs/ru/developers/payments/index.md +++ b/docs/ru/developers/payments/index.md @@ -1126,7 +1126,7 @@ sCommerce проверяет график и сохраняет его в `payme ### Оформление заказа и обмен -Покупатель указывает, сколько пустой тары он сдаёт. Отправьте `productId`, `optionId` и `quantity` POST-запросом на `scommerce/container-exchange` (маршрут `sCommerce.containerExchange`) или вызовите `sCart::setContainerExchange($productId, $optionId, $count)`; быстрые заказы принимают `exchange`. Количество ограничивается количеством в строке. sCommerce не ведёт остаток тары по покупателю: если курьер обнаружит другое количество, менеджер меняет **Тара в обмен** в строке заказа, и залог и стоимость заказа пересчитываются. +Покупатель указывает, сколько пустой тары он сдаёт. Такое указание отменяет залог ещё до того, как кто-либо увидел тару, поэтому для него нужна настройка **Покупатели указывают тару в обмен** (`product.container_exchange_on`, по умолчанию выключена); иначе маршрут отвечает `404`, `setContainerExchange()` завершается ошибкой, а указанный обмен игнорируется. Отправьте `productId`, `optionId` и `quantity` POST-запросом на `scommerce/container-exchange` (маршрут `sCommerce.containerExchange`) или вызовите `sCart::setContainerExchange($productId, $optionId, $count)`; быстрые заказы принимают `exchange`. Количество ограничивается количеством в строке. sCommerce не ведёт остаток тары по покупателю: если курьер обнаружит другое количество, менеджер меняет **Тара в обмен** в строке заказа, и залог и стоимость заказа пересчитываются. Строки корзины и строки заказа с залогом хранят: diff --git a/docs/uk/developers/deliveries.md b/docs/uk/developers/deliveries.md index d21192d..889a5dd 100644 --- a/docs/uk/developers/deliveries.md +++ b/docs/uk/developers/deliveries.md @@ -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 diff --git a/docs/uk/developers/events.md b/docs/uk/developers/events.md index 5080bf3..676312a 100644 --- a/docs/uk/developers/events.md +++ b/docs/uk/developers/events.md @@ -110,7 +110,10 @@ sCart::clearPriceMode(); - **Ціни без ПДВ** (`product.vat_display_on`, типово вимкнено) вмикає функцію. Поки її вимкнено, наведені нижче налаштування приховано, ціни завжди показуються з ПДВ, ключі для відображення дорівнюють цінам до сплати, а `scommerce/vat-display` відповідає `404`; - **Ставка ПДВ, %** (`product.vat_rate`, типово `0`): ставка, включена в ціни товарів. За `0` ціна нетто дорівнює ціні брутто; -- **Відображення цін** (`product.vat_display`, `gross` або `net`, типово `gross`): типове значення для відвідувачів, які не зробили вибір. +- **Типові ціни на вітрині** (`product.vat_display`, `gross` або `net`, типово `gross`): типове значення для відвідувачів, які не зробили вибір, наприклад `net` для B2B-магазину. Впливає лише на вітрину; +- **Без ПДВ** у **Представлення списку товарів** (`products.show_field_price_net`, типово увімкнено): колонка списку товарів у менеджері з ціною без ПДВ і включеним у неї роздрібним акцизом (`sProduct::priceNetTo()`), показується, поки увімкнено ціни без ПДВ. + +Типові значення для товарів, що продаються на міру, і округлення до кроку за одиницю налаштовуються в окремому блоці **Вимірювані кількості** на тій самій сторінці налаштувань. Ціна нетто обчислюється як `round(gross / (1 + rate / 100), 2)`. Шаблони читають такі значення: @@ -221,6 +224,23 @@ Event::listen('sCommerce.CheckoutValidationRules', function (array $params): voi Часткові форми без товарів не запускають правило для products. Атрибута мінімуму та окремого класу політики проєкту в пакеті немає. +Починаючи з 1.5.0 вітрина перевіряється перед збереженням замовлення: + +- `processOrder()` наново розраховує позиції, збережені з моменту `initCheckout()`, за поточними цінами, + публікацією, залишками та валютою. Якщо щось відрізняється, він зберігає поточні позиції й завершується + помилкою з `error_code` `cart_changed` і новими `products` та `cost`; покупець підтверджує замовлення ще раз. + Позиції понад залишок завершуються помилкою `out_of_stock`, зокрема у швидких замовленнях. +- Вартість доставки перераховується під час кожного `setOrderData()` і ще раз у `processOrder()` та + конвертується з валюти вартості способу у валюту замовлення. Невдала спроба залишає дані + оформлення без змін, тож повторна спроба ніколи не додає доставку двічі. +- Неопубліковані товари не можна додати до кошика чи замовити. Швидкі замовлення без `productId` + оформлюють кошик; будь-яке значення, крім цілого числа, вважається невідомим товаром. Штучна кількість + обмежена 1 000 000, а від'ємна ціна від слухача ніколи не зменшує суму. +- Маршрути `scommerce/process-order` і `scommerce/quick-order` повертають замовлення як + `sOrder::toCustomerArray()` (без нотаток менеджера, історії та `meta`) і дозволяють `orders.rate_limit` + замовлень з однієї IP-адреси за 10 хвилин (типово 10, 0 = без обмеження, **Налаштування → Кошик та замовлення**); + понад це вони відповідають `429` з `error_code` `rate_limited`. + Для цих трьох подій використовуйте точні назви з цієї сторінки, без префікса `evolution.`. Попередні цінові події `evolution.sCommerceResolveProductPriceMode` і `evolution.sCommerceResolveProductPrice` більше не викликаються; оновлюйте проєктні слухачі та sPricing разом із sCommerce. ### sCommerce.ResolveProductPriceMode @@ -663,6 +683,80 @@ Payload містить `event`, `productId`, `modificationId` (завжди `0`) `sCommerce::supportsIntegration('stock-provider')` повідомляє про підтримку. +## Розділення рядків кошика + +### sCommerce.SplitCartLine + +Починаючи з 1.5.0, слухач може продати одну позицію кошика (товар з опцією та кількістю) кількома рядками замовлення з різними цінами. У самому sCommerce немає партій залишків; ця подія дає змогу пакету партій або адаптеру ERP застосовувати власні правила. Типові випадки: + +- Підакцизні товари з ціною, зафіксованою для партії: одиниці старішої партії продаються за її ціною, перш ніж відкривається наступна партія. +- Швидкопсувні товари: одиниці партії, у якої близькі дата й час закінчення терміну придатності, продаються першими за уцінкою, решта одиниць тієї самої позиції — за звичайною ціною. +- Товари, які мають бути з однієї партії, наприклад плитка одного тону: слухач зазначає партію в рядку. + +Кошик, checkout і швидке замовлення одного товару викликають подію для кожної позиції після визначення її ціни. Payload містить `product`, `optionId`, `quantity` (базові одиниці для товарів, що продаються за мірою), `currency` і `pricing` (визначена ціна позиції, див. [sCommerce.ResolveProductPrice](#scommerceresolveproductprice)). Вирішує перший слухач, який повернув масив; поверніть `null`, щоб залишити позицію одним рядком. + +Відповідь — це список частин, кількості яких у сумі дають кількість позиції: + +| Ключ | Обов’язковий | Значення | +|---|---|---| +| `key` | так | Унікальний у межах позиції: від 1 до 40 літер, цифр, `.`, `_` або `-`, наприклад `lot-11` | +| `quantity` | так | Додатне ціле число | +| `priceAsFloat` | ні | Ціна за одиницю в `currency`; без неї частина отримує ціну позиції | +| `oldPriceAsFloat` | ні | Перекреслена ціна уцінки; лише разом із `priceAsFloat`, типово відсутня | +| `label` | ні | Текст до 255 символів для покупця та менеджера, наприклад `Best before 27.10 18:00` | +| `allocation` | ні | Масив, що кодується в JSON і зберігається в рядку замовлення як є, наприклад id партій і кількості | + +Будь-яка інша відповідь, наприклад кількості з іншою сумою, повторюваний ключ або від’ємна ціна, викидає `UnexpectedValueException`, тож помилка слухача ніколи не призводить до продажу за неправильною ціною. + +```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; +}); +``` + +Кожна частина стає одним рядком кошика та замовлення: + +- Її `line_key` — `{productId}:{optionId}:{key}`, наприклад `5:0:lot-11`. +- Її ціна, сума, ПДВ і ключі застави розраховуються для частини. Заявлені обміни тари заповнюють частини по черзі. +- Вона містить `part_key`, `part_label` (якщо задано) і `allocation` (якщо задано). Листи про замовлення, екран замовлення в менеджері та форма друку показують `part_label` під назвою товару. +- Лише в кошику `cart_quantity` — це кількість усієї позиції. Елемент зміни кількості розділеного рядка має надсилати цю кількість позиції, бо `sCart::addProduct()` встановлює кількість позиції, а не однієї частини. `cart_quantity` не зберігається в замовленнях. + +Checkout повторно розраховує ціни своїх рядків перед збереженням замовлення. Якщо розділення тим часом змінилося, наприклад партію розпродано, ключі рядків або ціни відрізняються, і покупець отримує `cart_changed` з новими рядками та підсумком. + +Слухач також має узгодити решту правил партій: + +- **Залишок.** Зробіть так, щоб `sCommerce.ResolveProductStock` або провайдер залишків повертав те, що можна продати. +- **Ціна в каталозі.** Зробіть так, щоб `sCommerce.ResolveProductPrice` повертав ціну першої одиниці для карток товарів. +- **Списання залишку.** Залишок партій списує `StockProviderInterface`, який читає `allocation` з рядків замовлення. sCommerce не інтерпретує розподіли. Його локальне списання залишку, як і раніше, враховує частини одного товару разом. + +Ключі частин залишаються на сервері: коли менеджер зберігає замовлення, рядки зберігають `part_key`, `part_label` і `allocation`, записані під їхнім `line_key`, а рядки, додані в менеджері, їх не мають. Менеджер, який змінює кількість розділеного рядка, змінює рядок, а не його розподіл; провайдер залишків отримує `sCommerce.OrderUpdated`. Повторні замовлення зберігають записані ціни та мітки, але відкидають `allocation`. + +`sCommerce::supportsIntegration('line-parts')` повідомляє про підтримку. + ## Додаткові події менеджера та frontend Події менеджера слухають із префіксом evolution. Події ціноутворення мають точні імена sCommerce.* без цього префікса. Для frontend використовуйте наведені callbacks; структура відповіді залежить від операції. diff --git a/docs/uk/developers/integration.md b/docs/uk/developers/integration.md index 3e69da7..d2c1876 100644 --- a/docs/uk/developers/integration.md +++ b/docs/uk/developers/integration.md @@ -291,7 +291,7 @@ class SCommerceProductController extends BaseController | Ключ | Тип | Значення | |---|---|---| -| `line_key` | string | `"{productId}:{optionId}"`, наприклад `"5:3"` | +| `line_key` | string | `"{productId}:{optionId}"`, наприклад `"5:3"`; рядки розділеної позиції кошика додають ключ частини: `"5:0:lot-11"` | | `product_id` | int | id товару (збігається з `id`) | | `option_id` | int | id опції кошика; `0` — опції немає | | `sku` | string | фактичний SKU; дорівнює `product_sku`, якщо слухач `sCommerce.ResolveLineIdentity` не задав SKU варіанта | @@ -303,6 +303,7 @@ class SCommerceProductController extends BaseController | `retail_excise_rate`, `retail_excise_amount` | float | лише в рядках із роздрібним акцизом: його ставка та сума, включена в суму рядка, для чека | | `deposit_unit`, `exchange_quantity`, `deposit_quantity`, `deposit_amount`, `deposit_returnable`, `deposit_terms`, `deposit_returned_quantity` | mixed | лише в рядках із заставною вартістю зворотної тари, див. [Заставна вартість зворотної тари](payments/index.md#заставна-вартість-зворотної-тари) | | `measure`, `base_unit`, `price_unit`, `price_per`, `factor`, `quantity_*`, `quantity_label`, `unit_price_label`, `line_weight`, `actual_quantity*` | mixed | лише в рядках товарів, що продаються на вагу, за довжиною, об’ємом або кількістю; тоді `quantity` вказується в базових одиницях (грами, міліметри, мілілітри, штуки). Див. [Вимірювані кількості](measured-quantities.md#рядки-замовлення) | +| `part_key`, `part_label`, `allocation` | string, string, array | лише в рядках позиції кошика, розділеної слухачем `sCommerce.SplitCartLine`, наприклад за партіями залишків: ключ частини, її мітка та розподіл слухача. Див. [Розділення рядків кошика](events.md#розділення-рядків-кошика) | Податкові слухачі також можуть додавати `tax_code`, `unit_price_gross`, `tax_amount` і `prices_include_tax`. Див. [Події](events.md#рядки-замовлення-виконання-та-податки). diff --git a/docs/uk/developers/payments/index.md b/docs/uk/developers/payments/index.md index d466c61..5734479 100644 --- a/docs/uk/developers/payments/index.md +++ b/docs/uk/developers/payments/index.md @@ -1126,7 +1126,7 @@ sCommerce перевіряє графік і зберігає його в `payme ### Оформлення замовлення та обмін -Покупець зазначає, скільки порожньої тари він здає. Надішліть `productId`, `optionId` і `quantity` POST-запитом на `scommerce/container-exchange` (маршрут `sCommerce.containerExchange`) або викличте `sCart::setContainerExchange($productId, $optionId, $count)`; швидкі замовлення приймають `exchange`. Кількість обмежується кількістю в рядку. sCommerce не веде залишку тари за покупцем: якщо кур’єр виявить іншу кількість, менеджер змінює **Тара в обмін** у рядку замовлення, і застава та вартість замовлення перераховуються. +Покупець зазначає, скільки порожньої тари він здає. Таке зазначення скасовує заставу ще до того, як хтось побачив тару, тому для нього потрібне налаштування **Покупці вказують тару в обмін** (`product.container_exchange_on`, типово вимкнено); інакше маршрут відповідає `404`, `setContainerExchange()` завершується помилкою, а зазначений обмін ігнорується. Надішліть `productId`, `optionId` і `quantity` POST-запитом на `scommerce/container-exchange` (маршрут `sCommerce.containerExchange`) або викличте `sCart::setContainerExchange($productId, $optionId, $count)`; швидкі замовлення приймають `exchange`. Кількість обмежується кількістю в рядку. sCommerce не веде залишку тари за покупцем: якщо кур’єр виявить іншу кількість, менеджер змінює **Тара в обмін** у рядку замовлення, і застава та вартість замовлення перераховуються. Рядки кошика та рядки замовлення із заставою зберігають: diff --git a/lang/de/global.php b/lang/de/global.php index 7c36ef7..bcf9bc4 100644 --- a/lang/de/global.php +++ b/lang/de/global.php @@ -163,6 +163,10 @@ "payment_reconciliation_worker_desc" => "Verbucht Offline-Zahlungen und schließt offene Zahlungsvorgänge ab.", "payment_reconciliation_worker_done" => "Verbuchte Zahlungen: :settled. Abgeschlossene Vorgänge: :resolved.", "checkout_context_unavailable" => "Der Bestellvorgang ist derzeit nicht verfügbar. Bitte später erneut versuchen oder uns kontaktieren.", + "checkout_cart_changed" => "Preise oder Verfügbarkeit Ihrer Bestellung haben sich geändert. Bitte die aktualisierte Gesamtsumme prüfen und die Bestellung erneut bestätigen.", + "order_rate_limited" => "Zu viele Bestellungen von Ihrer Adresse. Bitte in einigen Minuten erneut versuchen.", + "orders_rate_limit" => "Bestellungen pro Besucher in 10 Minuten", + "orders_rate_limit_help" => "Bestellungen und Schnellbestellungen, die von einer IP-Adresse innerhalb von 10 Minuten erlaubt sind; schützt vor einer Flut von Bestellungen und E-Mails. 0 = keine Begrenzung.", "delivery_not_required" => "Keine Lieferung erforderlich", "live_orders" => "Echte Bestellungen", "order_meta" => "Zusätzliche Bestelldaten", @@ -170,9 +174,11 @@ "test_orders" => "Testbestellungen", "mode_virtual" => "Virtuell", "mode_service" => "Dienstleistung", - "vat_display" => "Angezeigte Preise", + "move_down" => "Nach unten verschieben", + "move_up" => "Nach oben verschieben", + "vat_display" => "Shop-Preise standardmäßig", "vat_display_gross" => "Inkl. MwSt.", - "vat_display_help" => "Standardanzeige der Preise im Shop. Besucher können sie umschalten; der berechnete Preis enthält immer die MwSt.", + "vat_display_help" => "Wie Shop-Preise Besuchern angezeigt werden, die noch nicht umgeschaltet haben, z. B. ohne MwSt. für einen B2B-Shop. Besucher können die Anzeige umschalten; Listener von sCommerce.ResolveVatDisplay können pro Kunde entscheiden. Der berechnete Preis enthält immer die MwSt.", "vat_display_net" => "Ohne MwSt.", "vat_rate" => "MwSt.-Satz, %", "vat_rate_help" => "Standard-MwSt.-Satz, der in den Produktpreisen enthalten ist. Wird nur zur Anzeige von Preisen ohne MwSt. verwendet; 0 zeigt denselben Preis.", @@ -184,6 +190,8 @@ "container_deposit_help" => "Pfand für Mehrweggebinde pro Einheit, zusätzlich zum Preis und außerhalb der MwSt. berechnet. Im Tausch abgegebenes Leergut erhält kein neues Pfand.", "container_deposit_included" => "Inkl. Pfand", "container_exchange" => "Leergut im Tausch", + "container_exchange_on" => "Kunden geben getauschtes Leergut an", + "container_exchange_on_help" => "Kunden können im Warenkorb angeben, wie viel Leergut sie abgeben. Diese Einheiten werden ohne neues Pfand bestellt, bevor jemand das Leergut gesehen hat. Das Leergut daher bei der Lieferung prüfen.", "container_return" => "Leergut zurücknehmen", "container_return_cash" => "Barauszahlung", "container_return_count" => "Gebinde", @@ -206,6 +214,7 @@ "containers_help" => "Zurückgegebenes Leergut und die Erstattung oder Verrechnung seines Pfands, auch Rücknahmen ohne Bestellung.", "containers_icon" => "fa fa-recycle", "vat_display_on" => "Preise ohne MwSt.", + "show_field_price_net_help" => "Den Preis ohne MwSt. und die enthaltene Einzelhandels-Verbrauchsteuer in der Produktliste anzeigen. Verfügbar, solange Preise ohne MwSt. aktiviert sind.", "vat_display_on_help" => "Dem Shop erlauben, Preise ohne MwSt. anzuzeigen, z. B. für Geschäftskunden. Der berechnete Preis enthält immer die MwSt.", "retail_excise_on" => "Einzelhandels-Verbrauchsteuer", "retail_excise_on_help" => "Die in den Preisen enthaltene Einzelhandels-Verbrauchsteuer (Alkohol, Tabak, Kraftstoff) berechnen und für den Beleg in den Bestellpositionen speichern.", @@ -271,6 +280,7 @@ "quantity_min" => "Mindestmenge", "quantity_min_help" => "Die kleinste Bestellmenge in Basiseinheiten; mindestens ein Schritt. Wenn leer, gilt der Standardwert der Website.", "quantity_max" => "Höchstmenge", + "quantity_too_large" => "Die Menge darf nicht größer als :max sein.", "quantity_tolerance" => "Mehrmengentoleranz", "quantity_tolerance_help" => "Wie weit eine Ist-Menge (nach der Bestellung zugeschnitten oder gewogen) die bestellte Menge übersteigen darf, in Basispunkten: 500 = 5%. Wenn leer, gilt der Standardwert der Website.", "quantity_rounding" => "Rundung auf den Schritt", @@ -331,4 +341,5 @@ "actual_quantity_unavailable" => "Ist-Mengen erfordern die neuesten sCommerce-Migrationen.", "actual_quantity_stock" => "Nicht genügend Bestand für die Ist-Menge.", "permission_order_actual_over_tolerance" => "Toleranz der Ist-Menge überschreiten", + "currency_already_added" => "Diese Währung ist bereits in der Liste.", ]); diff --git a/lang/en/global.php b/lang/en/global.php index de45282..0710535 100644 --- a/lang/en/global.php +++ b/lang/en/global.php @@ -142,6 +142,10 @@ "checkout_settings" => "Cart and orders", "checkout_settings_intro" => "These settings define product attribute display in the cart and sCommerce order number generation rules.", "checkout_context_unavailable" => "This checkout is not available right now. Please try again later or contact us.", + "checkout_cart_changed" => "Prices or availability of your order have changed. Please check the updated total and confirm the order again.", + "order_rate_limited" => "Too many orders from your address. Please try again in a few minutes.", + "orders_rate_limit" => "Orders per visitor in 10 minutes", + "orders_rate_limit_help" => "Checkout and quick orders allowed from one IP address within 10 minutes; protects against order and email flooding. 0 = no limit.", "orders_reference_prefix" => "Order number prefix", "orders_reference_prefix_help" => "Prefix added before the sequential order number (e.g. #, ORD_).", "orders_reference_start" => "Starting number", @@ -159,6 +163,8 @@ "container_deposit_on" => "Returnable container deposits", "container_deposit_on_help" => "Charge a refundable deposit for containers such as water bottles, with exchanges and returns. Adds the deposit field to products and the Containers tab.", "container_exchange" => "Exchanged containers", + "container_exchange_on" => "Customers declare exchanged containers", + "container_exchange_on_help" => "Let customers state in the cart how many empty containers they hand over. Those units are ordered without a new deposit before anyone has seen the containers, so check them on delivery.", "container_return" => "Return containers", "container_return_cash" => "Cash refund", "container_return_count" => "Containers", @@ -191,6 +197,7 @@ "currencies_help" => "List of available store currencies and their relationship to each other", "currencies_icon" => "fa fa-coins", "currency" => "Currency", + "currency_already_added" => "This currency is already in the list.", "currency_conversion" => "Currency Conversion", "currency_name" => "Currency name", "currency_name_help" => "Currency name that can be displayed on the frontend if needed sCommerce::getCurrencies(['UAH'])->first()['name']", @@ -294,6 +301,8 @@ "mode_test" => "Test Mode", "mode_variable" => "Variable", "mode_virtual" => "Virtual", + "move_down" => "Move down", + "move_up" => "Move up", "modification_attributes" => "Modification attributes", "modifications" => "Modifications", "modifications_help" => "Product modifications are available.", @@ -543,11 +552,12 @@ "uploading_file" => "Uploading file", "user_unknown" => "User unknown", "value" => "Value", - "vat_display" => "Prices shown", + "vat_display" => "Storefront prices by default", "vat_display_gross" => "With VAT", - "vat_display_help" => "Default display of storefront prices. Visitors can switch it; the charged price always includes VAT.", + "vat_display_help" => "How storefront prices are shown to visitors who have not switched yet, e.g. without VAT for a B2B shop. Visitors can switch it; listeners of sCommerce.ResolveVatDisplay may decide per customer. The charged price always includes VAT.", "vat_display_net" => "Without VAT", "vat_display_on" => "Prices without VAT", + "show_field_price_net_help" => "Show the price without VAT and the included retail excise in the product list. Available while prices without VAT are on.", "vat_display_on_help" => "Let the storefront show prices without VAT, e.g. to business customers. The charged price always includes VAT.", "vat_rate" => "VAT rate, %", "vat_rate_help" => "Default VAT rate included in product prices. Used only to show prices without VAT; 0 shows the same price.", @@ -766,6 +776,7 @@ "quantity_min" => "Minimum quantity", "quantity_min_help" => "The smallest order in base units; at least one step. Empty uses the site default.", "quantity_max" => "Maximum quantity", + "quantity_too_large" => "The quantity may not be greater than :max.", "quantity_tolerance" => "Overrun tolerance", "quantity_tolerance_help" => "How much an actual quantity (cut or weighed after ordering) may exceed the ordered one, in basis points: 500 = 5%. Empty uses the site default.", "quantity_rounding" => "Rounding to the step", diff --git a/lang/fr/global.php b/lang/fr/global.php index c644487..3d9e6a4 100644 --- a/lang/fr/global.php +++ b/lang/fr/global.php @@ -163,6 +163,10 @@ "payment_reconciliation_worker_desc" => "Enregistre les paiements hors ligne et finalise les opérations de paiement inachevées.", "payment_reconciliation_worker_done" => "Paiements encaissés : :settled. Opérations finalisées : :resolved.", "checkout_context_unavailable" => "La commande n’est pas disponible pour le moment. Veuillez réessayer plus tard ou nous contacter.", + "checkout_cart_changed" => "Les prix ou la disponibilité de votre commande ont changé. Veuillez vérifier le nouveau total et confirmer à nouveau la commande.", + "order_rate_limited" => "Trop de commandes depuis votre adresse. Veuillez réessayer dans quelques minutes.", + "orders_rate_limit" => "Commandes par visiteur sur 10 minutes", + "orders_rate_limit_help" => "Commandes et commandes rapides autorisées depuis une même adresse IP en 10 minutes ; protège contre l’envoi massif de commandes et d’e-mails. 0 = aucune limite.", "delivery_not_required" => "Livraison non requise", "live_orders" => "Commandes réelles", "order_meta" => "Données supplémentaires de la commande", @@ -170,9 +174,11 @@ "test_orders" => "Commandes de test", "mode_virtual" => "Virtuel", "mode_service" => "Service", - "vat_display" => "Prix affichés", + "move_down" => "Déplacer vers le bas", + "move_up" => "Déplacer vers le haut", + "vat_display" => "Prix de la boutique par défaut", "vat_display_gross" => "TTC", - "vat_display_help" => "Affichage par défaut des prix de la boutique. Les visiteurs peuvent le changer ; le prix facturé inclut toujours la TVA.", + "vat_display_help" => "Affichage des prix de la boutique pour les visiteurs qui ne l’ont pas encore changé, par exemple HT pour une boutique B2B. Les visiteurs peuvent le changer ; les écouteurs de sCommerce.ResolveVatDisplay peuvent décider pour chaque client. Le prix facturé inclut toujours la TVA.", "vat_display_net" => "HT", "vat_rate" => "Taux de TVA, %", "vat_rate_help" => "Taux de TVA par défaut inclus dans les prix des produits. Utilisé uniquement pour afficher les prix HT ; 0 affiche le même prix.", @@ -184,6 +190,8 @@ "container_deposit_help" => "Consigne par unité pour l’emballage consigné, facturée en plus du prix et hors TVA. Les emballages vides rendus en échange ne portent pas de nouvelle consigne.", "container_deposit_included" => "Dont consigne", "container_exchange" => "Emballages échangés", + "container_exchange_on" => "Les clients déclarent les emballages échangés", + "container_exchange_on_help" => "Permet aux clients d’indiquer dans le panier combien d’emballages vides ils rendent. Ces unités sont commandées sans nouvelle consigne avant que quiconque ait vu les emballages ; vérifiez-les donc à la livraison.", "container_return" => "Reprendre des emballages", "container_return_cash" => "Remboursement en espèces", "container_return_count" => "Emballages", @@ -206,6 +214,7 @@ "containers_help" => "Emballages rendus et remboursement ou imputation de leur consigne, y compris les reprises sans commande.", "containers_icon" => "fa fa-recycle", "vat_display_on" => "Prix HT", + "show_field_price_net_help" => "Afficher le prix HT et l’accise sur la vente au détail incluse dans la liste des produits. Disponible tant que les prix HT sont activés.", "vat_display_on_help" => "Permettre à la boutique d’afficher les prix HT, par exemple aux clients professionnels. Le prix facturé inclut toujours la TVA.", "retail_excise_on" => "Accise sur la vente au détail", "retail_excise_on_help" => "Calculer l’accise sur la vente au détail incluse dans les prix (alcool, tabac, carburant) et l’enregistrer sur les lignes de commande pour le ticket de caisse.", @@ -271,6 +280,7 @@ "quantity_min" => "Quantité minimale", "quantity_min_help" => "La plus petite commande en unités de base ; au moins un pas. Si vide, la valeur par défaut du site est utilisée.", "quantity_max" => "Quantité maximale", + "quantity_too_large" => "La quantité ne peut pas dépasser :max.", "quantity_tolerance" => "Tolérance de dépassement", "quantity_tolerance_help" => "De combien une quantité réelle (coupée ou pesée après la commande) peut dépasser la quantité commandée, en points de base : 500 = 5%. Si vide, la valeur par défaut du site est utilisée.", "quantity_rounding" => "Arrondi au pas", @@ -331,4 +341,5 @@ "actual_quantity_unavailable" => "Les quantités réelles nécessitent les dernières migrations sCommerce.", "actual_quantity_stock" => "Stock insuffisant pour la quantité réelle.", "permission_order_actual_over_tolerance" => "Dépasser la tolérance de quantité réelle", + "currency_already_added" => "Cette devise est déjà dans la liste.", ]); diff --git a/lang/pl/global.php b/lang/pl/global.php index e8e3d81..c8fe391 100644 --- a/lang/pl/global.php +++ b/lang/pl/global.php @@ -163,6 +163,10 @@ "payment_reconciliation_worker_desc" => "Rozlicza płatności offline i kończy niezakończone operacje płatnicze.", "payment_reconciliation_worker_done" => "Rozliczone płatności: :settled. Zakończone operacje: :resolved.", "checkout_context_unavailable" => "Składanie zamówienia jest obecnie niedostępne. Spróbuj ponownie później lub skontaktuj się z nami.", + "checkout_cart_changed" => "Ceny lub dostępność Twojego zamówienia uległy zmianie. Sprawdź zaktualizowaną sumę i ponownie potwierdź zamówienie.", + "order_rate_limited" => "Zbyt wiele zamówień z Twojego adresu. Spróbuj ponownie za kilka minut.", + "orders_rate_limit" => "Zamówienia na odwiedzającego w ciągu 10 minut", + "orders_rate_limit_help" => "Liczba zamówień i szybkich zamówień dozwolona z jednego adresu IP w ciągu 10 minut; chroni przed zalewem zamówień i e-maili. 0 = bez limitu.", "delivery_not_required" => "Dostawa nie jest wymagana", "live_orders" => "Rzeczywiste zamówienia", "order_meta" => "Dodatkowe dane zamówienia", @@ -170,9 +174,11 @@ "test_orders" => "Zamówienia testowe", "mode_virtual" => "Wirtualny", "mode_service" => "Usługa", - "vat_display" => "Wyświetlane ceny", + "move_down" => "Przenieś w dół", + "move_up" => "Przenieś w górę", + "vat_display" => "Domyślne ceny w sklepie", "vat_display_gross" => "Z VAT", - "vat_display_help" => "Domyślny sposób wyświetlania cen w sklepie. Odwiedzający mogą go przełączyć; pobierana cena zawsze zawiera VAT.", + "vat_display_help" => "Jak ceny w sklepie są pokazywane odwiedzającym, którzy jeszcze nie przełączyli wyświetlania, np. bez VAT w sklepie B2B. Odwiedzający mogą je przełączyć; słuchacze sCommerce.ResolveVatDisplay mogą decydować dla każdego klienta. Pobierana cena zawsze zawiera VAT.", "vat_display_net" => "Bez VAT", "vat_rate" => "Stawka VAT, %", "vat_rate_help" => "Domyślna stawka VAT zawarta w cenach produktów. Używana tylko do wyświetlania cen bez VAT; 0 pokazuje tę samą cenę.", @@ -184,6 +190,8 @@ "container_deposit_help" => "Kaucja za opakowanie zwrotne za sztukę, naliczana ponad cenę i poza VAT. Puste opakowania oddane w wymianie nie są obciążane nową kaucją.", "container_deposit_included" => "W tym kaucja za opakowania", "container_exchange" => "Opakowania w wymianie", + "container_exchange_on" => "Klienci deklarują opakowania w wymianie", + "container_exchange_on_help" => "Pozwala klientom podać w koszyku, ile pustych opakowań oddają. Te sztuki są zamawiane bez nowej kaucji, zanim ktokolwiek zobaczy opakowania, więc sprawdź je przy dostawie.", "container_return" => "Przyjmij zwrot opakowań", "container_return_cash" => "Zwrot gotówką", "container_return_count" => "Opakowania", @@ -206,6 +214,7 @@ "containers_help" => "Zwrócone opakowania oraz zwrot lub zaliczenie ich kaucji, także zwroty bez zamówienia.", "containers_icon" => "fa fa-recycle", "vat_display_on" => "Ceny bez VAT", + "show_field_price_net_help" => "Pokazuj cenę bez VAT i zawartą w niej akcyzę detaliczną na liście produktów. Dostępne, gdy ceny bez VAT są włączone.", "vat_display_on_help" => "Pozwól sklepowi pokazywać ceny bez VAT, np. klientom biznesowym. Pobierana cena zawsze zawiera VAT.", "retail_excise_on" => "Akcyza detaliczna", "retail_excise_on_help" => "Obliczaj akcyzę detaliczną zawartą w cenach (alkohol, tytoń, paliwo) i zapisuj ją w pozycjach zamówienia na potrzeby paragonu.", @@ -271,6 +280,7 @@ "quantity_min" => "Minimalna ilość", "quantity_min_help" => "Najmniejsze zamówienie w jednostkach bazowych; co najmniej jeden krok. Puste pole oznacza wartość domyślną witryny.", "quantity_max" => "Maksymalna ilość", + "quantity_too_large" => "Ilość nie może być większa niż :max.", "quantity_tolerance" => "Tolerancja nadwyżki", "quantity_tolerance_help" => "O ile ilość rzeczywista (docięta lub zważona po zamówieniu) może przekroczyć zamówioną, w punktach bazowych: 500 = 5%. Puste pole oznacza wartość domyślną witryny.", "quantity_rounding" => "Zaokrąglanie do kroku", @@ -331,4 +341,5 @@ "actual_quantity_unavailable" => "Ilości rzeczywiste wymagają najnowszych migracji sCommerce.", "actual_quantity_stock" => "Za mały stan magazynowy dla rzeczywistej ilości.", "permission_order_actual_over_tolerance" => "Przekraczanie tolerancji rzeczywistej ilości", + "currency_already_added" => "Ta waluta jest już na liście.", ]); diff --git a/lang/ru/global.php b/lang/ru/global.php index 34578c7..ee48a9f 100644 --- a/lang/ru/global.php +++ b/lang/ru/global.php @@ -142,6 +142,10 @@ "checkout_settings" => "Корзина и заказы", "checkout_settings_intro" => "Эти настройки определяют отображение атрибутов товаров в корзине и правила формирования номеров заказов sCommerce.", "checkout_context_unavailable" => "Оформление заказа сейчас недоступно. Попробуйте позже или свяжитесь с нами.", + "checkout_cart_changed" => "Цены или наличие товаров в вашем заказе изменились. Проверьте обновлённую сумму и подтвердите заказ ещё раз.", + "order_rate_limited" => "Слишком много заказов с вашего адреса. Попробуйте ещё раз через несколько минут.", + "orders_rate_limit" => "Заказов от посетителя за 10 минут", + "orders_rate_limit_help" => "Сколько заказов и быстрых заказов разрешено с одного IP-адреса за 10 минут; защищает от потока заказов и писем. 0 = без ограничения.", "orders_reference_prefix" => "Префикс номера", "orders_reference_prefix_help" => "Префикс, который добавляется перед порядковым номером (например: #, ORD_).", "orders_reference_start" => "Начальный номер", @@ -159,6 +163,8 @@ "container_deposit_on" => "Залог за возвратную тару", "container_deposit_on_help" => "Начислять возвращаемый залог за тару, например за бутыли для воды, с обменом и возвратом. Добавляет поле залога в товары и вкладку «Тара».", "container_exchange" => "Тара в обмен", + "container_exchange_on" => "Покупатели указывают тару в обмен", + "container_exchange_on_help" => "Позволяет покупателям указать в корзине, сколько пустой тары они сдают. Эти единицы заказываются без нового залога ещё до того, как кто-либо увидел тару, поэтому проверяйте её при доставке.", "container_return" => "Вернуть тару", "container_return_cash" => "Возврат наличными", "container_return_count" => "Количество тары", @@ -192,6 +198,7 @@ "currencies_help" => "Список доступных валют магазина и их соотношение друг к другу.", "currencies_icon" => "fa fa-coins", "currency" => "Валюта", + "currency_already_added" => "Эта валюта уже есть в списке.", "currency_conversion" => "Конвертация валют", "currency_name" => "Название валюты", "currency_name_help" => "Название валюты, которое можно отобразить на фронтенде при необходимости sCommerce::getCurrencies(['UAH'])->first()['name']", @@ -295,6 +302,8 @@ "mode_test" => "Тестовый режим", "mode_variable" => "Вариативный", "mode_virtual" => "Виртуальный", + "move_down" => "Переместить вниз", + "move_up" => "Переместить вверх", "modification_attributes" => "Атрибуты модификаций", "modifications" => "Модификации", "modifications_help" => "Доступные модификации товара.", @@ -544,11 +553,12 @@ "uploading_file" => "Загружаю файл", "user_unknown" => "Пользователь не известен", "value" => "Значение", - "vat_display" => "Отображение цен", + "vat_display" => "Цены на витрине по умолчанию", "vat_display_gross" => "С НДС", - "vat_display_help" => "Отображение цен на сайте по умолчанию. Посетители могут его переключить; цена к оплате всегда включает НДС.", + "vat_display_help" => "Как цены на витрине показываются посетителям, которые ещё не переключили отображение, например без НДС для B2B-магазина. Посетители могут его переключить; обработчики sCommerce.ResolveVatDisplay могут решать для каждого клиента. Цена к оплате всегда включает НДС.", "vat_display_net" => "Без НДС", "vat_display_on" => "Цены без НДС", + "show_field_price_net_help" => "Показывать цену без НДС и включённый в неё розничный акциз в списке товаров. Доступно, пока включены цены без НДС.", "vat_display_on_help" => "Разрешить витрине показывать цены без НДС, например бизнес-клиентам. Цена к оплате всегда включает НДС.", "vat_rate" => "Ставка НДС, %", "vat_rate_help" => "Ставка НДС по умолчанию, включённая в цены товаров. Используется только для показа цен без НДС; 0 показывает ту же цену.", @@ -767,6 +777,7 @@ "quantity_min" => "Минимальное количество", "quantity_min_help" => "Наименьший заказ в базовых единицах; не меньше одного шага. Если не заполнено, используется значение сайта по умолчанию.", "quantity_max" => "Максимальное количество", + "quantity_too_large" => "Количество не может быть больше :max.", "quantity_tolerance" => "Допуск превышения", "quantity_tolerance_help" => "Насколько фактическое количество (отрезанное или взвешенное после заказа) может превышать заказанное, в базисных пунктах: 500 = 5%. Если не заполнено, используется значение сайта по умолчанию.", "quantity_rounding" => "Округление до шага", diff --git a/lang/uk/global.php b/lang/uk/global.php index 2f68d1d..e5482a3 100644 --- a/lang/uk/global.php +++ b/lang/uk/global.php @@ -142,6 +142,10 @@ "checkout_settings" => "Кошик та замовлення", "checkout_settings_intro" => "Ці налаштування визначають відображення товарних атрибутів у кошику та правила формування номерів замовлень sCommerce.", "checkout_context_unavailable" => "Оформлення замовлення зараз недоступне. Спробуйте пізніше або зв'яжіться з нами.", + "checkout_cart_changed" => "Ціни або наявність товарів у вашому замовленні змінилися. Перевірте оновлену суму та підтвердьте замовлення ще раз.", + "order_rate_limited" => "Забагато замовлень з вашої адреси. Спробуйте ще раз за кілька хвилин.", + "orders_rate_limit" => "Замовлень від відвідувача за 10 хвилин", + "orders_rate_limit_help" => "Скільки замовлень і швидких замовлень дозволено з однієї IP-адреси за 10 хвилин; захищає від потоку замовлень і листів. 0 = без обмеження.", "orders_reference_prefix" => "Префікс номера", "orders_reference_prefix_help" => "Префікс, який буде додано перед порядковим номером (наприклад: #, ORD_).", "orders_reference_start" => "Початковий номер", @@ -159,6 +163,8 @@ "container_deposit_on" => "Заставна вартість зворотної тари", "container_deposit_on_help" => "Нараховувати заставу за тару, що повертається, наприклад за бутлі для води, з обміном і поверненням. Додає поле застави до товарів і вкладку «Тара».", "container_exchange" => "Тара в обмін", + "container_exchange_on" => "Покупці вказують тару в обмін", + "container_exchange_on_help" => "Дозволяє покупцям вказати в кошику, скільки порожньої тари вони здають. Ці одиниці замовляються без нової застави ще до того, як хтось побачив тару, тож перевіряйте її під час доставки.", "container_return" => "Повернути тару", "container_return_cash" => "Повернення готівкою", "container_return_count" => "Кількість тари", @@ -192,6 +198,7 @@ "currencies_help" => "Список доступних валют магазину та їх відношення одна до одної.", "currencies_icon" => "fa fa-coins", "currency" => "Валюта", + "currency_already_added" => "Ця валюта вже є в списку.", "currency_conversion" => "Конвертація валют", "currency_name" => "Назва валюти", "currency_name_help" => "Назва валюти, яку можна відобразити на фронтенд за потреби sCommerce::getCurrencies(['UAH'])->first()['name']", @@ -295,6 +302,8 @@ "mode_test" => "Тестовий режим", "mode_variable" => "Варіативний", "mode_virtual" => "Віртуальний", + "move_down" => "Перемістити вниз", + "move_up" => "Перемістити вгору", "modification_attributes" => "Атрибути модифікацій", "modifications" => "Модифікації", "modifications_help" => "Доступні модифікації товару.", @@ -544,11 +553,12 @@ "uploading_file" => "Завантажую файл", "user_unknown" => "Користувач не відомий", "value" => "Значення", - "vat_display" => "Відображення цін", + "vat_display" => "Типові ціни на вітрині", "vat_display_gross" => "З ПДВ", - "vat_display_help" => "Типове відображення цін на сайті. Відвідувачі можуть його перемкнути; ціна до сплати завжди містить ПДВ.", + "vat_display_help" => "Як ціни на вітрині показуються відвідувачам, які ще не перемкнули відображення, наприклад без ПДВ для B2B-магазину. Відвідувачі можуть його перемкнути; обробники sCommerce.ResolveVatDisplay можуть вирішувати для кожного клієнта. Ціна до сплати завжди містить ПДВ.", "vat_display_net" => "Без ПДВ", "vat_display_on" => "Ціни без ПДВ", + "show_field_price_net_help" => "Показувати ціну без ПДВ і включений у неї роздрібний акциз у списку товарів. Доступно, поки увімкнено ціни без ПДВ.", "vat_display_on_help" => "Дозволити вітрині показувати ціни без ПДВ, наприклад бізнес-клієнтам. Ціна до сплати завжди містить ПДВ.", "vat_rate" => "Ставка ПДВ, %", "vat_rate_help" => "Типова ставка ПДВ, включена в ціни товарів. Використовується лише для показу цін без ПДВ; 0 показує ту саму ціну.", @@ -768,6 +778,7 @@ "quantity_min" => "Мінімальна кількість", "quantity_min_help" => "Найменше замовлення в базових одиницях; щонайменше один крок. Якщо не заповнено, використовується типове значення сайту.", "quantity_max" => "Максимальна кількість", + "quantity_too_large" => "Кількість не може бути більшою за :max.", "quantity_tolerance" => "Допуск перевищення", "quantity_tolerance_help" => "Наскільки фактична кількість (відрізана або зважена після замовлення) може перевищувати замовлену, у базисних пунктах: 500 = 5%. Якщо не заповнено, використовується типове значення сайту.", "quantity_rounding" => "Округлення до кроку", diff --git a/src/Cart/CartLineParts.php b/src/Cart/CartLineParts.php new file mode 100644 index 0000000..353e3a8 --- /dev/null +++ b/src/Cart/CartLineParts.php @@ -0,0 +1,208 @@ + $pricing Resolved price of the entry, see {@see \Seiger\sCommerce\Services\sPriceResolver::resolve()}. + * @param string $currency Display currency of the prices. + * @return list|null + * @throws \UnexpectedValueException When the listener response is not a valid split of `$quantity`. + * @since 1.5.0 + */ + public static function resolve(object $product, int $optionId, int $quantity, array $pricing, string $currency): ?array + { + if ($quantity < 1) { + return null; + } + foreach (Event::dispatch(self::EVENT, [[ + 'product' => $product, + 'optionId' => $optionId, + 'quantity' => $quantity, + 'currency' => $currency, + 'pricing' => $pricing, + ]]) as $response) { + if (is_array($response)) { + return self::normalize($response, $quantity, (int)($product->id ?? 0)); + } + } + + return null; + } + + /** + * Pricing of a part: the entry pricing, or the part price with its old price (default none). + * + * @param array $pricing Entry pricing. + * @param array{priceAsFloat: float|null, oldPriceAsFloat: float|null} $part + * @return array + * @since 1.5.0 + */ + public static function pricing(array $pricing, array $part, string $currency): array + { + if ($part['priceAsFloat'] === null) { + return $pricing; + } + $old = (float)($part['oldPriceAsFloat'] ?? 0); + + return array_replace($pricing, [ + 'priceAsFloat' => $part['priceAsFloat'], + 'oldPriceAsFloat' => $old, + 'price' => sCommerce::convertPrice($part['priceAsFloat'], $currency, $currency), + 'oldPrice' => $old > 0 ? sCommerce::convertPrice($old, $currency, $currency) : '', + ]); + } + + /** + * Mark a cart line built for a part. + * + * @param array $line Line built for the part quantity. + * @param array{key: string, label: string|null, allocation: array|null} $part + * @param int $cartQuantity Quantity of the whole cart entry, the value a quantity control changes. + * @return array + * @since 1.5.0 + */ + public static function apply(array $line, array $part, int $cartQuantity): array + { + $line['line_key'] = $line['line_key'] . ':' . $part['key']; + $line['part_key'] = $part['key']; + $line[self::CART_QUANTITY] = $cartQuantity; + if ($part['label'] !== null) { + $line['part_label'] = $part['label']; + } + if ($part['allocation'] !== null) { + $line['allocation'] = $part['allocation']; + } + + return $line; + } + + /** + * Keep part keys of edited order lines server-side: posted values are dropped, and a line + * whose `line_key` matches a stored line gets the stored part keys back. + * + * @param array $stored Stored order lines. + * @param array $edited Lines posted by the manager form. + * @return array + * @since 1.5.0 + */ + public static function restoreStored(array $stored, array $edited): array + { + $byKey = []; + foreach ($stored as $line) { + if (is_array($line) && is_string($line['line_key'] ?? null) && !isset($byKey[$line['line_key']])) { + $byKey[$line['line_key']] = array_intersect_key($line, array_flip(self::LINE_KEYS)); + } + } + foreach ($edited as $index => $line) { + if (is_array($line)) { + $key = is_string($line['line_key'] ?? null) ? $line['line_key'] : null; + $edited[$index] = array_diff_key($line, array_flip([...self::LINE_KEYS, self::CART_QUANTITY])) + ($key !== null ? $byKey[$key] ?? [] : []); + } + } + + return $edited; + } + + /** + * @return list + */ + private static function normalize(array $response, int $quantity, int $productId): array + { + $fail = static fn (string $reason) => new \UnexpectedValueException(self::EVENT . " returned an invalid split of product {$productId}: {$reason}."); + if ($response === [] || !array_is_list($response)) { + throw $fail('expected a non-empty list of parts'); + } + $parts = []; + $total = 0; + foreach ($response as $index => $part) { + if (!is_array($part)) { + throw $fail("part {$index} is not an array"); + } + $key = $part['key'] ?? null; + if (!is_string($key) || !preg_match(self::KEY_PATTERN, $key) || isset($parts[$key])) { + throw $fail("part {$index} needs a unique key of 1-40 letters, digits, '.', '_' or '-'"); + } + $units = $part['quantity'] ?? null; + if (!is_int($units) || $units < 1) { + throw $fail("part {$key} needs a positive integer quantity"); + } + $price = self::amount($part, 'priceAsFloat') ?? throw $fail("part {$key} has an invalid priceAsFloat"); + $old = self::amount($part, 'oldPriceAsFloat') ?? throw $fail("part {$key} has an invalid oldPriceAsFloat"); + if ($price === false && $old !== false) { + throw $fail("part {$key} sets oldPriceAsFloat without priceAsFloat"); + } + $label = $part['label'] ?? null; + if ($label !== null && (!is_string($label) || mb_strlen($label) > self::LABEL_MAX)) { + throw $fail("part {$key} has a label that is not a string of up to " . self::LABEL_MAX . ' characters'); + } + $allocation = $part['allocation'] ?? null; + if ($allocation !== null) { + try { + // Allocations are stored in the order JSON. + json_encode($allocation, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $allocation = false; + } + if (!is_array($allocation)) { + throw $fail("part {$key} has an allocation that is not a JSON-encodable array"); + } + } + $label = $label === null || trim($label) === '' ? null : trim($label); + $parts[$key] = ['key' => $key, 'quantity' => $units, 'priceAsFloat' => $price === false ? null : $price, + 'oldPriceAsFloat' => $old === false ? null : $old, 'label' => $label, 'allocation' => $allocation]; + $total += $units; + } + if ($total !== $quantity) { + throw $fail("parts hold {$total} units instead of {$quantity}"); + } + + return array_values($parts); + } + + /** + * A non-negative finite amount, false when absent, null when invalid. + */ + private static function amount(array $part, string $key): float|false|null + { + if (!array_key_exists($key, $part)) { + return false; + } + $value = $part[$key]; + if ((!is_int($value) && !is_float($value)) || !is_finite((float)$value) || $value < 0) { + return null; + } + + return (float)$value; + } +} diff --git a/src/Cart/sCart.php b/src/Cart/sCart.php index 9c411f3..346ab43 100644 --- a/src/Cart/sCart.php +++ b/src/Cart/sCart.php @@ -12,6 +12,7 @@ use Seiger\sCommerce\Services\OrderLineIdentity; use Seiger\sCommerce\Services\QuantityRules; use Seiger\sCommerce\Services\StockResolver; +use Seiger\sCommerce\Services\StorefrontGuard; use Seiger\sCommerce\Services\VatDisplay; use Seiger\sCommerce\Services\sPriceResolver; @@ -130,7 +131,7 @@ public function addProduct(int $productId = 0, int $optionId = 0, int $quantity $product = sCommerce::getProduct($productId); - if (!$product || !$product->id) { + if (!StorefrontGuard::purchasable($product)) { $message = __('sCommerce::global.product_with_id', ['id' => $productId]) . __('sCommerce::global.not_found') . '.'; Log::channel('scommerce')->warning('sCart - ' . $message); @@ -154,6 +155,9 @@ public function addProduct(int $productId = 0, int $optionId = 0, int $quantity } $quantity = MeasuredQuantity::normalize($requested, $rules); $adjusted = $quantity !== $requested; + } elseif ($quantity > $rules->max) { + $quantity = $rules->max; + $adjusted = true; } $this->storeQuantity($productId, $optionId, $quantity); @@ -234,6 +238,11 @@ public function removeProduct(int $productId = 0): array } } + $productId = is_int($productId) || (is_string($productId) && ctype_digit($productId)) ? (int)$productId : 0; + if ($productId < 1) { + return ['success' => false, 'message' => __('sCommerce::global.product_with_id', ['id' => 0]) . __('sCommerce::global.not_found') . '.']; + } + unset($this->cartData[$productId]); $this->saveCartData(); @@ -252,6 +261,7 @@ public function removeProduct(int $productId = 0): array * * Exchanged units carry no new deposit. The count is the customer's declaration, capped at * the line quantity; the courier or manager corrects the order when it differs. + * Refused unless {@see ContainerDeposit::exchangeEnabled()}. * * @param int $productId Product id; read from the request when 0. * @param int $optionId Cart option id; read from the request when not given. @@ -265,6 +275,9 @@ public function setContainerExchange(int $productId = 0, ?int $optionId = null, $optionId ??= request()->integer('optionId'); $exchange ??= request()->integer('quantity'); $item = $this->cartData[$productId][$optionId] ?? null; + if (!ContainerDeposit::exchangeEnabled()) { + return ['success' => false, 'message' => 'Container exchanges are not enabled.']; + } if ($item === null) { return ['success' => false, 'message' => __('sCommerce::global.product_with_id', ['id' => $productId]) . __('sCommerce::global.not_found') . '.']; } @@ -291,10 +304,26 @@ public function setContainerExchange(int $productId = 0, ?int $optionId = null, * Lines of products sold by measure carry their quantity in base units, the rule keys of * {@see QuantityRules::lineKeys()}, `quantity_label`, `unit_price_label` and `line_weight`; * their sum is rounded half up once from the unrounded price ({@see MeasuredQuantity}). + * A cart entry split by a `sCommerce.SplitCartLine` listener gives one item per part, each with its + * own quantity and price and the keys of {@see CartLineParts::apply()}; `cart_quantity` is the + * quantity of the whole entry. * * @return array{totalSum: float, totalSumFormatted: string, totalSumNet: float, totalSumNetFormatted: string, totalSumDisplay: float, totalSumDisplayFormatted: string, totalRetailExcise: float, totalRetailExciseFormatted: string, vatDisplay: string, items: array>} */ public function getMiniCart(): array + { + return $this->getCartFor($this->cartData); + } + + /** + * Cart payload of {@see getMiniCart()} for `[productId => [optionId => quantity]]` with current + * prices; a line may be `['quantity' => int, 'exchange' => int]`. Unpublished and unknown + * products are left out. + * + * @param array> $cartData + * @since 1.5.0 + */ + public function getCartFor(array $cartData): array { $totalSum = 0; $totalNet = 0.0; @@ -303,44 +332,58 @@ public function getMiniCart(): array $currency = sCommerce::currentCurrency(); $vatDisplay = VatDisplay::mode(); $items = []; - $productIds = array_keys($this->cartData); - $products = sCommerce::getProducts($productIds); + $productIds = array_keys($cartData); + $products = $productIds === [] ? [] : sCommerce::getProducts($productIds); + $exchangeEnabled = ContainerDeposit::exchangeEnabled(); foreach ($products as $product) { + if (!StorefrontGuard::purchasable($product) || !is_array($cartData[$product->id] ?? null)) { + continue; + } $rules = MeasuredQuantity::rules($product); - foreach ($this->cartData[$product->id] as $optionId => $cartItem) { - $quantity = $this->getCartItemQuantity($cartItem, $rules->min); - $deposit = ContainerDeposit::lineFields(ContainerDeposit::unit($product, (int)$optionId, $currency), $quantity, - is_array($cartItem) ? (int)($cartItem['exchange'] ?? 0) : 0); - $pricing = $this->resolveProductPricing($product, (int)$optionId); - $price = (float)$pricing['priceAsFloat']; - $lineSum = $rules->isMeasured() - ? MeasuredQuantity::lineTotalMinor(MeasuredQuantity::scalePrice($price), $quantity, $rules->factor) / 100 - : $price * $quantity; - $sumAsFloat = round($lineSum, 3); - $sum = sCommerce::convertPrice($sumAsFloat); - $vat = $this->vatPricing($product, $pricing, (int)$optionId); - $sumNetAsFloat = VatDisplay::net($sumAsFloat, (float)$vat['vatRate'], (float)$vat['retailExciseRate']); - $sumExciseAsFloat = VatDisplay::excise($sumAsFloat, (float)$vat['retailExciseRate']); - $displaySumAsFloat = $vatDisplay === VatDisplay::NET ? $sumNetAsFloat : $sumAsFloat; - $item = array_merge( - $this->getProductFields($product, $pricing), - app(OrderLineIdentity::class)->snapshot($product, (int)$optionId), - $vat, - $deposit, - compact('quantity', 'sumAsFloat', 'sum'), - ['sumNetAsFloat' => $sumNetAsFloat, 'sumNet' => sCommerce::convertPrice($sumNetAsFloat), - 'displaySumAsFloat' => $displaySumAsFloat, 'displaySum' => sCommerce::convertPrice($displaySumAsFloat), - 'sumRetailExciseAsFloat' => $sumExciseAsFloat, 'sumRetailExcise' => $sumExciseAsFloat > 0 ? sCommerce::convertPrice($sumExciseAsFloat) : ''] - ); - if ($rules->isMeasured()) { - $item += MeasuredQuantity::lineFields($rules, $quantity, (string)($pricing['price'] ?? ''), (int)($item['weight'] ?? 0)); + foreach ($cartData[$product->id] as $optionId => $cartItem) { + $cartQuantity = $this->getCartItemQuantity($cartItem, $rules->min, $rules->max); + $exchange = $exchangeEnabled && is_array($cartItem) ? (int)($cartItem['exchange'] ?? 0) : 0; + $entryPricing = $this->resolveProductPricing($product, (int)$optionId); + $parts = CartLineParts::resolve($product, (int)$optionId, $cartQuantity, $entryPricing, $currency); + foreach ($parts ?? [null] as $part) { + $quantity = $part['quantity'] ?? $cartQuantity; + $pricing = $part ? CartLineParts::pricing($entryPricing, $part, $currency) : $entryPricing; + // Declared container exchanges fill the parts in order. + $partExchange = min($exchange, $quantity); + $exchange -= $partExchange; + $deposit = ContainerDeposit::lineFields(ContainerDeposit::unit($product, (int)$optionId, $currency), $quantity, $partExchange); + // A negative price from a listener never lowers the order total. + $price = max(0.0, (float)$pricing['priceAsFloat']); + $lineSum = $rules->isMeasured() + ? MeasuredQuantity::lineTotalMinor(MeasuredQuantity::scalePrice($price), $quantity, $rules->factor) / 100 + : $price * $quantity; + $sumAsFloat = round($lineSum, 3); + $sum = sCommerce::convertPrice($sumAsFloat); + $vat = $this->vatPricing($product, $pricing, (int)$optionId); + $sumNetAsFloat = VatDisplay::net($sumAsFloat, (float)$vat['vatRate'], (float)$vat['retailExciseRate']); + $sumExciseAsFloat = VatDisplay::excise($sumAsFloat, (float)$vat['retailExciseRate']); + $displaySumAsFloat = $vatDisplay === VatDisplay::NET ? $sumNetAsFloat : $sumAsFloat; + $item = array_merge( + $this->getProductFields($product, $pricing), + app(OrderLineIdentity::class)->snapshot($product, (int)$optionId), + $vat, + $deposit, + compact('quantity', 'sumAsFloat', 'sum'), + ['sumNetAsFloat' => $sumNetAsFloat, 'sumNet' => sCommerce::convertPrice($sumNetAsFloat), + 'displaySumAsFloat' => $displaySumAsFloat, 'displaySum' => sCommerce::convertPrice($displaySumAsFloat), + 'sumRetailExciseAsFloat' => $sumExciseAsFloat, 'sumRetailExcise' => $sumExciseAsFloat > 0 ? sCommerce::convertPrice($sumExciseAsFloat) : ''] + ); + if ($rules->isMeasured()) { + // Physical fields stay out of the cart payload; only the line weight is derived from them. + $item += MeasuredQuantity::lineFields($rules, $quantity, (string)($pricing['price'] ?? ''), (int)($product->physicalPayload()['weight'] ?? 0)); + } + $items[] = $part ? CartLineParts::apply($item, $part, $cartQuantity) : $item; + $totalSum += $lineSum; + $totalNet += $sumNetAsFloat; + $totalExcise += $sumExciseAsFloat; + $totalDeposit += (float)($deposit['deposit_amount'] ?? 0); } - $items[] = $item; - $totalSum += $lineSum; - $totalNet += $sumNetAsFloat; - $totalExcise += $sumExciseAsFloat; - $totalDeposit += (float)($deposit['deposit_amount'] ?? 0); } } $totalDeposit = round($totalDeposit, 2); @@ -447,11 +490,41 @@ private function storeQuantity(int $productId, int $optionId, int $quantity): vo } /** - * Stored quantity of a cart line, at least `$min` (the minimum of a measured product). + * Stored quantity of a cart line, from `$min` to `$max` (the limits of the product). + */ + private function getCartItemQuantity(mixed $cartItem, int $min = 1, int $max = MeasuredQuantity::PIECE_MAX): int + { + return min($max, is_array($cartItem) ? max($min, (int)($cartItem['quantity'] ?? 1)) : max($min, (int)$cartItem)); + } + + /** + * Titles of products with less stock than the cart needs, while `product.inventory_on` is set. + * Virtual and service lines are not counted against inventory. + * + * @param array>|null $cartData Lines as in {@see getCartFor()}; the session cart by default. + * @return list + * @since 1.5.0 */ - private function getCartItemQuantity(mixed $cartItem, int $min = 1): int + public function shortages(?array $cartData = null): array { - return is_array($cartItem) ? max($min, (int)($cartItem['quantity'] ?? 1)) : max($min, (int)$cartItem); + $cartData ??= $this->cartData; + if (!sCommerce::config('product.inventory_on', 0) || $cartData === []) { + return []; + } + $shortages = []; + foreach (sCommerce::getProducts(array_keys($cartData)) as $product) { + foreach ((array)($cartData[$product->id] ?? []) as $optionId => $cartItem) { + if (!$this->tracksInventory($product, (int)$optionId)) { + continue; + } + $quantity = $this->getCartItemQuantity($cartItem, 1, PHP_INT_MAX); + if (app(StockResolver::class)->available($product, 0, ['optionId' => (int)$optionId, 'quantity' => $quantity]) < $quantity) { + $shortages[] = (string)($product->title ?? $product->id); + } + } + } + + return array_values(array_unique($shortages)); } private function resolveProductPriceMode(object $product, int $optionId = 0): string diff --git a/src/Checkout/sCheckout.php b/src/Checkout/sCheckout.php index 212dc64..27851b0 100644 --- a/src/Checkout/sCheckout.php +++ b/src/Checkout/sCheckout.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; +use Seiger\sCommerce\Cart\CartLineParts; use Seiger\sCommerce\Events\EventScope; use Seiger\sCommerce\Facades\sCart; use Seiger\sCommerce\Facades\sCommerce; @@ -21,6 +22,7 @@ use Seiger\sCommerce\Services\MeasuredQuantityException; use Seiger\sCommerce\Services\OrderLineIdentity; use Seiger\sCommerce\Services\OrderTaxes; +use Seiger\sCommerce\Services\StorefrontGuard; use Seiger\sCommerce\Services\VatDisplay; use Seiger\sCommerce\Services\sPriceResolver; use View; @@ -460,11 +462,9 @@ public function setOrderData(array $data): array $this->orderData = array_replace_recursive($this->orderData, $resultData); - if ($this->requiresDelivery()) { - if (!empty($validatedData['delivery']['method']) && isset($this->deliveryMethods[$this->orderData['delivery']['method']])) { - $deliveryMethod = $this->deliveryMethods[$this->orderData['delivery']['method']]; - $this->orderData['delivery']['cost'] = $deliveryMethod->calculateCost($this->orderData); - } + // Recalculated on every change, so an address changed after the method keeps no stale cost. + if (($deliveryMethod = $this->selectedDeliveryMethod()) !== null) { + $this->orderData['delivery']['cost'] = $this->deliveryCost($deliveryMethod); } $_SESSION['sCheckout'] = $this->orderData; @@ -498,12 +498,28 @@ public function processOrder(): array { // One scope: the order and its payment_info update produce a single OrderCreated. $scope = EventScope::begin('storefront', (int)($this->orderData['user']['id'] ?? 0)); + // A failed attempt leaves the checkout as it was, so a retry never adds the delivery twice. + $initial = $this->orderData; + $hadSession = isset($_SESSION) && array_key_exists('sCheckout', $_SESSION); + $initialSession = $_SESSION['sCheckout'] ?? null; + $rollback = function () use ($initial, $hadSession, $initialSession): void { + $this->orderData = $initial; + if ($hadSession) { + $_SESSION['sCheckout'] = $initialSession; + } else { + unset($_SESSION['sCheckout']); + } + }; try { - if (empty($this->orderData['products'])) { + if (empty($this->orderData['products']) || !is_array($this->orderData['products'])) { throw new \Exception("Invalid products in Order."); } Validator::make($this->orderData, $this->getValidationRules($this->orderData))->validate(); + if (($changed = $this->refreshProducts()) !== null) { + return $changed; + } + $this->checkoutContext = CheckoutContext::resolve($this->orderData, 'storefront', self::trustedActorId()); $paymentMethod = null; @@ -511,13 +527,15 @@ public function processOrder(): array $paymentSuccess = true; if ($this->requiresDelivery()) { - $deliveryMethod = $this->deliveryMethods[$this->orderData['delivery']['method'] ?? ''] ?? null; + $deliveryMethod = $this->selectedDeliveryMethod(); if (!$deliveryMethod) { - throw new \Exception('Invalid delivery method ' . ($this->orderData['delivery']['method'] ?? '') . '.'); + throw new \Exception('Invalid delivery method.'); } - $this->orderData['cost'] += $this->orderData['delivery']['cost'] ?? 0; + // Never the cost stored in the session: the address or currency may have changed since. + $this->orderData['delivery']['cost'] = $this->deliveryCost($deliveryMethod); + $this->orderData['cost'] += $this->orderData['delivery']['cost']; } elseif (sCommerce::config('basic.deliveries_on', 1) == 1) { $this->orderData['delivery'] = ['method' => '', 'required' => false, 'cost' => 0]; } @@ -528,6 +546,7 @@ public function processOrder(): array if (!$paymentMethod || !$paymentMethod->validatePayment($this->orderData)) { evo()->logEvent(0, 3, 'Payment method ' . $this->orderData['payment']['method'] . ' is Invalid or not available. Please check your API credentials.', 'sCommerce: Payment ' . $this->orderData['payment']['method'] . ' Failed'); Log::channel('scommerce')->error("Order processing failed: Payment method {$this->orderData['payment']['method']} is Invalid or not available. Please check your API credentials.", ['order_data' => $this->orderData]); + $rollback(); return [ 'success' => false, @@ -620,12 +639,14 @@ public function processOrder(): array return $response; } catch (CheckoutContextException $e) { + $rollback(); return [ 'success' => false, 'message' => $e->getMessage(), 'error_code' => 0, ]; } catch (\Exception $e) { + $rollback(); evo()->logEvent(0, 3, $e->getMessage(), 'sCommerce: Order Processing Failed'); Log::channel('scommerce')->error("Order processing failed: {$e->getMessage()}", ['order_data' => $this->orderData, 'error' => $e->getTraceAsString()]); @@ -659,7 +680,12 @@ public static function quickOrder(array $data) { // Product lines are built by the server below, never accepted from the request. unset($data['products']); - $product = sCommerce::getProduct($data['productId']); + // No product id means the cart; anything but a whole number is an unknown product. + $rawProductId = $data['productId'] ?? 0; + $productId = is_int($rawProductId) || (is_string($rawProductId) && ctype_digit(trim($rawProductId))) ? (int)$rawProductId + : (in_array($rawProductId, [null, ''], true) ? 0 : -1); + $data['productId'] = $productId; + $product = $productId > 0 ? sCommerce::getProduct($productId) : null; $userId = evo()->getLoginUserID('web') ?: evo()->getLoginUserID('mgr'); // Checking if the user is authorized $user = evo()->getUserInfo($userId ?: 0) ?: []; $user = array_merge($user, evo()->getUserSettings()); @@ -683,7 +709,9 @@ public static function quickOrder(array $data) if (empty($userData['phone']) && empty($userData['email'])) { return [ 'success' => false, - 'product' => $product?->toArray() ?? [], + // Never the product row: it holds wholesale prices, stock and unpublished products. + 'product' => StorefrontGuard::purchasable($product) + ? ['id' => (int)$product->id, 'title' => (string)($product->title ?? ''), 'link' => (string)($product->link ?? '')] : [], 'message' => 'Either phone number or email is required.', 'errors' => ['User is undefined'], ]; @@ -694,12 +722,12 @@ public static function quickOrder(array $data) } catch (CheckoutContextException $e) { return [ 'success' => false, - 'product' => (int)$data['productId'], + 'product' => max(0, $productId), 'message' => $e->getMessage(), ]; } - if (empty($data['productId']) || $data['productId'] == 0) { + if ($productId === 0) { $cartData = sCart::getMiniCart(); if (empty($cartData['items'])) { return [ @@ -711,7 +739,7 @@ public static function quickOrder(array $data) $productsData = $cartData['items']; $cost = $cartData['totalSum']; } else { - if (!$product) { + if (!StorefrontGuard::purchasable($product)) { return [ 'success' => false, 'message' => 'Product not found.', @@ -724,16 +752,15 @@ public static function quickOrder(array $data) if ($rules->isMeasured()) { try { if (is_scalar($data['amount'] ?? null) && trim((string)$data['amount']) !== '') { - $quantity = MeasuredQuantity::parseAmount((string)$data['amount'], $rules); + $quantity = MeasuredQuantity::normalize(MeasuredQuantity::parseAmount((string)$data['amount'], $rules), $rules); } } catch (MeasuredQuantityException $e) { return [ 'success' => false, - 'product' => (int)$data['productId'], + 'product' => max(0, $productId), 'message' => $e->translated(), ]; } - $quantity = MeasuredQuantity::normalize($quantity, $rules); } $pricing = static::resolveProductPricing($product, $optionId); @@ -756,7 +783,6 @@ public static function quickOrder(array $data) $productData['priceMode'] = $pricing['priceMode']; } $productData = array_merge($productData, app(OrderLineIdentity::class)->snapshot($product, $optionId)); - $productData = array_merge($productData, $physical = $product->physicalPayload()); $productsData = [$productData]; } @@ -768,25 +794,48 @@ public static function quickOrder(array $data) 'errors' => $validator->errors(), 'error_code' => 'checkout_validation']; } - if (!empty($data['productId']) && $data['productId'] != 0) { - $quantity = (int) $quantity; - $productsData[0]['quantity'] = $quantity; - $price = (float) $pricing['priceAsFloat']; + if ($productId > 0) { + // Quantity-dependent totals are calculated only after the raw quantity has passed validation. + + if ($rules->isMeasured()) { + $quantity = MeasuredQuantity::normalize($quantity, $rules); + } elseif ($quantity > $rules->max) { + return ['success' => false, 'message' => __('sCommerce::global.quantity_too_large', ['max' => $rules->max]), + 'error_code' => 'checkout_validation']; + } + if (($shortages = sCart::shortages([$productId => [$optionId => $quantity]])) !== []) { + return self::outOfStock($shortages); + } + $productData = $productsData[0]; + $productData['quantity'] = $quantity; + // A negative price from a listener never lowers the order total. + $price = max(0.0, (float) $pricing['priceAsFloat']); $cost = $rules->isMeasured() ? MeasuredQuantity::lineTotalMinor(MeasuredQuantity::scalePrice($price), $quantity, $rules->factor) / 100 : $price * $quantity; - // Calculate deposit amounts only after the raw quantity has passed validation. - $productsData[0] = array_merge($productsData[0], ContainerDeposit::lineFields( - ContainerDeposit::unit($product, $optionId), $quantity, max(0, (int)($data['exchange'] ?? 0)) + + // Units exchanged for empty returnable containers carry no new deposit. + $productData = array_merge($productData, ContainerDeposit::lineFields( + ContainerDeposit::unit($product, $optionId), $quantity, + ContainerDeposit::exchangeEnabled() ? max(0, (int)($data['exchange'] ?? 0)) : 0 )); - $cost += (float) ($productsData[0]['deposit_amount'] ?? 0); - $productsData[0] += MeasuredQuantity::lineFields( - $rules, $quantity, (string)($pricing['price'] ?? ''), (int)($physical['weight'] ?? 0) - ); + $cost += (float) ($productData['deposit_amount'] ?? 0); + + $productData += MeasuredQuantity::lineFields($rules, $quantity, (string)($pricing['price'] ?? ''), (int)($physical['weight'] ?? 0)); if ($rules->isMeasured()) { - $productsData[0]['sumAsFloat'] = round($cost, 3); - $productsData[0]['sum'] = sCommerce::convertPrice($productsData[0]['sumAsFloat']); + $productData['sumAsFloat'] = round($cost, 3); + $productData['sum'] = sCommerce::convertPrice($productData['sumAsFloat']); + } + $productsData = [$productData]; + // A split entry, e.g. by stock lots, is priced part by part like the cart. + if (CartLineParts::resolve($product, $optionId, $quantity, $pricing, sCommerce::currentCurrency()) !== null) { + $exchange = ContainerDeposit::exchangeEnabled() ? max(0, (int)($data['exchange'] ?? 0)) : 0; + $cart = sCart::getCartFor([$productId => [$optionId => $exchange > 0 ? ['quantity' => $quantity, 'exchange' => $exchange] : $quantity]]); + $productsData = array_map(static fn (array $line) => $line + $physical, $cart['items']); + $cost = $cart['totalSum']; } + } elseif (($shortages = sCart::shortages()) !== []) { + return self::outOfStock($shortages); } do { @@ -835,7 +884,7 @@ public static function quickOrder(array $data) } catch (\InvalidArgumentException|CheckoutContextException $e) { return [ 'success' => false, - 'product' => (int)$data['productId'], + 'product' => max(0, $productId), 'message' => $e->getMessage(), ]; } @@ -850,7 +899,7 @@ public static function quickOrder(array $data) }); }); - if ($data['productId'] == 0) { + if ($productId === 0) { unset($_SESSION['sCheckout'], $_SESSION['sCart']); } @@ -968,6 +1017,109 @@ public function registerPaymentMethod(PaymentMethodInterface $method): void $this->paymentMethods[$method->getIdentifier()] = $method; } + /** + * Re-price the checkout lines with current prices, publication and stock before the order is saved. + * + * The checkout keeps the lines priced when it started. When a line, its quantity, the currency + * or the total differs from the current catalog, the lines are replaced with current ones and + * the attempt fails with `cart_changed`, so the customer confirms the new total. + * + * @return array|null Failure response, or null when the order may be saved. + */ + private function refreshProducts(): ?array + { + $lineKey = static fn (array $line, int $productId, int $optionId): string => is_string($line['line_key'] ?? null) ? $line['line_key'] : $productId . ':' . $optionId; + $cartData = []; + $stored = []; + foreach ($this->orderData['products'] as $line) { + $productId = is_array($line) ? (int)($line['product_id'] ?? $line['id'] ?? 0) : 0; + $optionId = is_array($line) ? (int)($line['option_id'] ?? 0) : 0; + $quantity = is_array($line) ? (int)($line['quantity'] ?? 0) : 0; + $stored[] = [is_array($line) ? $lineKey($line, $productId, $optionId) : '', $quantity]; + if ($productId > 0 && $quantity > 0) { + // Parts of a split cart entry add up to the entry again. + $entry = $cartData[$productId][$optionId] ?? ['quantity' => 0, 'exchange' => 0]; + $cartData[$productId][$optionId] = ['quantity' => $entry['quantity'] + $quantity, + 'exchange' => $entry['exchange'] + (int)($line['exchange_quantity'] ?? 0)]; + } + } + foreach ($cartData as $productId => $entries) { + foreach ($entries as $optionId => $entry) { + $cartData[$productId][$optionId] = $entry['exchange'] > 0 ? $entry : $entry['quantity']; + } + } + + if (($shortages = sCart::shortages($cartData)) !== []) { + return self::outOfStock($shortages); + } + + $cart = sCart::getCartFor($cartData); + // A changed split, e.g. a stock lot sold out meanwhile, changes the line keys. + $current = array_map(static fn (array $line) => [$lineKey($line, (int)$line['product_id'], (int)$line['option_id']), (int)$line['quantity']], $cart['items']); + sort($stored); + sort($current); + $currency = sCommerce::currentCurrency(); + if ($current !== $stored || ($this->orderData['currency'] ?? $currency) !== $currency + || abs((float)$cart['totalSum'] - (float)($this->orderData['cost'] ?? 0)) >= 0.005) { + $this->orderData['products'] = $cart['items']; + $this->orderData['cost'] = $cart['totalSum']; + $this->orderData['currency'] = $currency; + $_SESSION['sCheckout'] = $this->orderData; + + return [ + 'success' => false, + 'message' => __('sCommerce::global.checkout_cart_changed'), + 'error_code' => 'cart_changed', + 'products' => $cart['items'], + 'cost' => $cart['totalSum'], + ]; + } + $this->orderData['products'] = $cart['items']; + + return null; + } + + /** + * Delivery method chosen in the checkout data while a delivery is needed. + */ + private function selectedDeliveryMethod(): ?DeliveryMethodInterface + { + $method = $this->orderData['delivery']['method'] ?? null; + + return $this->requiresDelivery() && is_string($method) ? ($this->deliveryMethods[$method] ?? null) : null; + } + + /** + * Delivery cost in the order currency. A method states its cost in the currency of its settings row + * (`getCostCurrency()`), else in the main shop currency. Negative and non-finite costs count as 0. + */ + private function deliveryCost(DeliveryMethodInterface $method): float + { + $cost = (float)$method->calculateCost($this->orderData); + if (!is_finite($cost) || $cost <= 0) { + return 0.0; + } + $from = method_exists($method, 'getCostCurrency') ? strtoupper(trim((string)$method->getCostCurrency())) : ''; + $from = $from !== '' ? $from : strtoupper(trim((string)sCommerce::config('basic.main_currency', 'USD'))); + $to = strtoupper(trim((string)($this->orderData['currency'] ?? ''))) ?: sCommerce::currentCurrency(); + + return round($from === $to ? $cost : (float)sCommerce::convertPriceNumber($cost, $from, $to), 2); + } + + /** + * Failure response for lines with less stock than ordered. + * + * @param list $titles Product titles. + */ + private static function outOfStock(array $titles): array + { + return [ + 'success' => false, + 'message' => __('sCommerce::global.product_title_is_out_of_stock', ['title' => implode(', ', $titles)]), + 'error_code' => 'out_of_stock', + ]; + } + /** * Load order data from the session or database. * @@ -1119,9 +1271,9 @@ private static function prepareNewOrder(sOrder $order, array $data, string $sour if ($canStore) { CheckoutContext::apply($order, $context); } - // VAT display values depend on the visitor's mode; they are not order data. + // VAT display values depend on the visitor's mode and the cart entry quantity is a cart control; they are not order data. if (is_array($order->products)) { - $display = array_flip(VatDisplay::keys()); + $display = array_flip([...VatDisplay::keys(), CartLineParts::CART_QUANTITY]); $order->products = OrderTaxes::applyRetailExcise(array_map(static fn ($line) => is_array($line) ? array_diff_key($line, $display) : $line, $order->products)); } diff --git a/src/Controllers/sCommerceController.php b/src/Controllers/sCommerceController.php index c0a6178..1813de2 100644 --- a/src/Controllers/sCommerceController.php +++ b/src/Controllers/sCommerceController.php @@ -163,6 +163,10 @@ public function updateFileConfigs(): bool } } + if (array_key_exists('basic__available_currencies', $all)) { + $all['basic__available_currencies'] = static::uniqueCurrencyCodes($all['basic__available_currencies']); + } + if (isset($all['orders__marketing_fields'])) { $all['orders__marketing_fields'] = trim(preg_replace('/["\\\\$\[\]{}]/', '', str_replace(["\r\n", "\r", "\n"], '||', (string)$all['orders__marketing_fields'])), '| '); } @@ -217,6 +221,25 @@ public function updateFileConfigs(): bool return true; } + /** + * Available currency codes in submitted order, without blanks and repeats. + * + * @param mixed $codes + * @return list + * @since 1.5.0 + */ + public static function uniqueCurrencyCodes(mixed $codes): array + { + $unique = []; + foreach ((array)$codes as $code) { + $code = is_scalar($code) ? trim((string)$code) : ''; + if ($code !== '' && !in_array($code, $unique, true)) { + $unique[] = $code; + } + } + return $unique; + } + /** * Translation keys of settings refused by the last {@see self::updateFileConfigs()}. * diff --git a/src/Delivery/BaseDeliveryMethod.php b/src/Delivery/BaseDeliveryMethod.php index eed94a5..f60d7e1 100644 --- a/src/Delivery/BaseDeliveryMethod.php +++ b/src/Delivery/BaseDeliveryMethod.php @@ -33,6 +33,19 @@ public function __construct() ]); } + /** + * Currency of {@see calculateCost()}: the currency of the method's settings row, else the main shop currency. + * Checkout converts the cost into the order currency. + * + * @since 1.5.0 + */ + public function getCostCurrency(): string + { + $currency = strtoupper(trim((string)($this->method->currency ?? ''))); + + return $currency !== '' ? $currency : strtoupper(trim((string)\Seiger\sCommerce\Facades\sCommerce::config('basic.main_currency', 'USD'))); + } + /** * Get the localized title for the delivery method. * diff --git a/src/Delivery/CourierDelivery.php b/src/Delivery/CourierDelivery.php index e6622db..dcb584e 100644 --- a/src/Delivery/CourierDelivery.php +++ b/src/Delivery/CourierDelivery.php @@ -83,20 +83,22 @@ public function getValidationRules(): array * Calculate the cost of delivery based on the order data. * * Calculates the total cost by adding a base cost and an extra cost depending on the city. + * Cities match case-insensitively in any script; the cost is in {@see getCostCurrency()}. * * @param array $orderData The order data, including user and address information. * @return float The calculated delivery cost. */ public function calculateCost(array $order): float { - $baseCost = floatval($this->method->cost ?? 0); - $distanceCost = 0; + $baseCost = max(0.0, floatval($this->method->cost ?? 0)); + $distanceCost = 0.0; $city = $order['delivery']['courier']['city'] ?? $order['user']['address']['city'] ?? ''; + $key = static fn ($value): string => is_scalar($value) ? mb_strtolower(trim((string)$value)) : ''; - if (!empty($city)) { - $city = strtolower(trim($city)); - $citySettings = collect($this->getSettings()['cities'] ?? [])->firstWhere('city', strtolower($city)); - $distanceCost = $citySettings['extra_cost'] ?? 0; + if ($key($city) !== '') { + $citySettings = collect($this->getSettings()['cities'] ?? []) + ->first(static fn ($row) => is_array($row) && $key($row['city'] ?? '') === $key($city)); + $distanceCost = max(0.0, floatval($citySettings['extra_cost'] ?? 0)); } return $baseCost + $distanceCost; diff --git a/src/Http/routes.php b/src/Http/routes.php index e68b869..f120cf0 100644 --- a/src/Http/routes.php +++ b/src/Http/routes.php @@ -5,6 +5,8 @@ use Seiger\sCommerce\Facades\sCart; use Seiger\sCommerce\Facades\sCheckout; use Seiger\sCommerce\Facades\sWishlist; +use Seiger\sCommerce\Models\sOrder; +use Seiger\sCommerce\Services\StorefrontGuard; use Seiger\sCommerce\sCommerce; Route::middleware('web')->prefix('scommerce/')->name('sCommerce.')->group(function () { @@ -20,10 +22,20 @@ sCheckout::setOrderData(request()->all()), fn($result) => response()->json($result, $result['success'] === true ? 200 : 422) ); - $processOrderHandler = fn() => tap( - sCheckout::processOrder(), - fn($result) => response()->json($result, $result['success'] === true ? 200 : 422) - ); + // Orders are limited per client, and the customer gets the order without manager notes, history or meta. + $placeOrder = static function (callable $place) { + if (!StorefrontGuard::orderAllowed()) { + return response()->json(['success' => false, 'message' => __('sCommerce::global.order_rate_limited'), 'error_code' => 'rate_limited'], 429); + } + $result = $place(); + if (($result['order'] ?? null) instanceof sOrder) { + StorefrontGuard::orderPlaced(); + $result['order'] = $result['order']->toCustomerArray(); + } + + return $result; + }; + $processOrderHandler = fn() => $placeOrder(static fn () => sCheckout::processOrder()); $payOrderHandler = function ($method = null) { $result = sCheckout::payOrder($method, request()->all()); if (request()->header('Accept') && strpos(request()->header('Accept'), 'application/json') !== false) { @@ -33,10 +45,7 @@ $_SESSION['payOrderResponse'] = $result; return back(); }; - $quickOrderHandler = fn() => tap( - sCheckout::quickOrder(request()->all()), - fn($result) => response()->json($result, $result['success'] === true ? 200 : 422) - ); + $quickOrderHandler = fn() => $placeOrder(static fn () => sCheckout::quickOrder(request()->all())); // Display only: show prices with (gross) or without (net) VAT to this visitor. $vatDisplayHandler = function () { if (!\Seiger\sCommerce\Services\VatDisplay::enabled()) { @@ -51,9 +60,9 @@ return response()->json(['success' => true, 'vatDisplay' => sCart::getVatDisplay(), 'miniCart' => sCart::getMiniCart()]); }; // Empty returnable containers the customer hands over for a cart line (no new deposit for them). - $containerExchangeHandler = fn() => \Seiger\sCommerce\Services\ContainerDeposit::enabled() + $containerExchangeHandler = fn() => \Seiger\sCommerce\Services\ContainerDeposit::exchangeEnabled() ? tap(sCart::setContainerExchange(), fn($result) => response()->json($result, $result['success'] === true ? 200 : 422)) - : response()->json(['success' => false, 'message' => 'Container deposits are not enabled.'], 404); + : response()->json(['success' => false, 'message' => 'Container exchanges are not enabled.'], 404); $wishlistHandler = fn() => tap( sWishlist::updateWishlist(request()->all()), fn($result) => response()->json($result, $result['success'] === true ? 200 : 422) diff --git a/src/Models/sOrder.php b/src/Models/sOrder.php index c0eb359..42a2e83 100644 --- a/src/Models/sOrder.php +++ b/src/Models/sOrder.php @@ -261,6 +261,21 @@ public function getOrderNumberAttribute(): ?string return OrderReferenceFormatter::orderNumber($this->reference ?? null); } + /** Attributes the storefront may show the customer; manager notes, history and `meta` stay internal. */ + public const CUSTOMER_KEYS = ['id', 'uuid', 'identifier', 'reference', 'user_info', 'delivery_info', 'payment_info', 'products', + 'cost', 'currency', 'status', 'payment_status', 'is_quick', 'comment', 'totals', 'created_at']; + + /** + * The order as the storefront returns it to the customer who placed it. + * + * @return array + * @since 1.5.0 + */ + public function toCustomerArray(): array + { + return ['order_number' => $this->order_number] + array_intersect_key($this->toArray(), array_flip(self::CUSTOMER_KEYS)); + } + /** * Orders of the live environment: everything except trusted test checkouts. * diff --git a/src/Models/sProduct.php b/src/Models/sProduct.php index 3d22cdb..aed5a51 100644 --- a/src/Models/sProduct.php +++ b/src/Models/sProduct.php @@ -830,6 +830,19 @@ public function priceTo($currency, string $priceMode = 'auto'): string return $this->formatPriceValue($this->priceToNumber($currency, $priceMode), $currency); } + /** + * The price of the manager product list ({@see activePricingPriceToNumber()} or {@see priceToNumber()}) + * without VAT and the included retail excise, formatted in `$currency`. + * + * @since 1.5.0 + */ + public function priceNetTo(string $currency): string + { + $gross = $this->activePricingPriceToNumber($currency, 'price') ?? $this->priceToNumber($currency); + + return $this->formatPriceValue(VatDisplay::net($gross, VatDisplay::rate($this), VatDisplay::exciseRate($this)), $currency); + } + /** * Convert the effective storefront price to a number in a specified currency. * diff --git a/src/Services/ContainerDeposit.php b/src/Services/ContainerDeposit.php index 7b69342..15bf768 100644 --- a/src/Services/ContainerDeposit.php +++ b/src/Services/ContainerDeposit.php @@ -48,6 +48,17 @@ public static function enabled(): bool return (int)sCommerce::config('product.container_deposit_on', 0) === 1; } + /** + * Whether customers may declare empty containers they exchange (`product.container_exchange_on`). + * The declaration waives the deposit before anyone has seen the containers, so it is off by default. + * + * @since 1.5.0 + */ + public static function exchangeEnabled(): bool + { + return self::enabled() && (int)sCommerce::config('product.container_exchange_on', 0) === 1; + } + /** * Deposit per unit of a product in `$currency`; the first non-negative numeric listener response wins. * Always 0 while the feature is off, and for products sold by weight, length, volume or count. diff --git a/src/Services/OrderLineEditor.php b/src/Services/OrderLineEditor.php index 5281abf..6dace7c 100644 --- a/src/Services/OrderLineEditor.php +++ b/src/Services/OrderLineEditor.php @@ -1,14 +1,15 @@ products) ? $order->products : []; $currency = (string)$order->currency; - $lines = app(OrderLineIdentity::class)->fillMissing(MeasuredQuantity::restoreStoredRules($stored, $edited), $currency); + $edited = CartLineParts::restoreStored($stored, MeasuredQuantity::restoreStoredRules($stored, $edited)); + $lines = app(OrderLineIdentity::class)->fillMissing($edited, $currency); foreach ($lines as $index => $line) { if (!is_array($line) || !isset($line['measure'])) { continue; diff --git a/src/Services/OrderRepeater.php b/src/Services/OrderRepeater.php index c2982f9..36a41c3 100644 --- a/src/Services/OrderRepeater.php +++ b/src/Services/OrderRepeater.php @@ -33,8 +33,8 @@ public function draft(sOrder $source): sOrder $products = $source->products ?? []; $cost = 0.0; foreach ($products as &$product) { - // The new order bills what was ordered; actual quantities belong to the source order. - $product = array_diff_key($product, array_flip(MeasuredQuantity::ACTUAL_KEYS), ['actual_quantity_label' => 0]); + // The new order bills what was ordered; actual quantities and stock allocations belong to the source order. + $product = array_diff_key($product, array_flip(MeasuredQuantity::ACTUAL_KEYS), ['actual_quantity_label' => 0, 'allocation' => 0]); $product += ['title' => '', 'sku' => '', 'link' => '', 'coverSrc' => '']; $price = isset($product['priceNumber']) && is_numeric($product['priceNumber']) ? (float) $product['priceNumber'] diff --git a/src/Services/StorefrontGuard.php b/src/Services/StorefrontGuard.php new file mode 100644 index 0000000..93566e2 --- /dev/null +++ b/src/Services/StorefrontGuard.php @@ -0,0 +1,78 @@ +id ?? 0) < 1) { + return false; + } + + return !isset($product->published) || (int)$product->published === 1; + } + + /** + * Whether the client may place another order: fewer than `orders.rate_limit` orders + * from its IP address in {@see ORDER_WINDOW}. 0 disables the limit. Without a cache store + * orders are allowed, so a cache fault never stops sales. + * + * @since 1.5.0 + */ + public static function orderAllowed(): bool + { + [$limiter, $key, $limit] = self::orderLimiter(); + + return $limiter === null || !$limiter->tooManyAttempts($key, $limit); + } + + /** + * Count a placed order against the client's limit. + * + * @since 1.5.0 + */ + public static function orderPlaced(): void + { + [$limiter, $key] = self::orderLimiter(); + $limiter?->hit($key, self::ORDER_WINDOW); + } + + /** + * @return array{0: ?RateLimiter, 1: string, 2: int} + */ + private static function orderLimiter(): array + { + $limit = (int)sCommerce::config('orders.rate_limit', 10); + $key = 'scommerce-orders:' . sha1((string)(request()->ip() ?? '')); + if ($limit < 1) { + return [null, $key, $limit]; + } + try { + $cache = Container::getInstance()->make('cache')->store(); + } catch (\Throwable $exception) { + Log::channel('scommerce')->warning('sCommerce order rate limit is unavailable without a cache store', ['error' => $exception->getMessage()]); + return [null, $key, $limit]; + } + + return [new RateLimiter($cache), $key, $limit]; + } +} diff --git a/src/sCommerce.php b/src/sCommerce.php index 5e16d4d..76b6f08 100644 --- a/src/sCommerce.php +++ b/src/sCommerce.php @@ -88,7 +88,7 @@ public static function integrationFeatures(): array { return ['order-events', 'payment-ledger-api', 'webhook-interface', 'payment-capabilities', 'payment-schedule', 'offline-settlement', 'line-identity', 'fulfilment-modes', 'line-tax-data', 'checkout-context', 'vat-display', 'retail-excise', 'container-deposit', 'shipments', 'stock-provider', - 'measured-quantities', 'actual-quantities']; + 'measured-quantities', 'actual-quantities', 'line-parts']; } /** diff --git a/tests/standalone/README.md b/tests/standalone/README.md index 3ac08ef..331d98c 100644 --- a/tests/standalone/README.md +++ b/tests/standalone/README.md @@ -31,12 +31,15 @@ php tests/standalone/scommerce-shipments.php php tests/standalone/scommerce-stock.php php tests/standalone/scommerce-measured-quantities.php php tests/standalone/scommerce-actual-quantities.php +php tests/standalone/scommerce-line-parts.php +php tests/standalone/scommerce-currency-settings.php node tests/standalone/scommerce-bulk-status.mjs node tests/standalone/scommerce-bulk-export.mjs node tests/standalone/scommerce-review.mjs node tests/standalone/scommerce-repeat.mjs node tests/standalone/scommerce-payment-actions.mjs node tests/standalone/scommerce-measured-quantities.mjs +node tests/standalone/scommerce-currency-settings.mjs ``` `scommerce-payment-ledger.php` also runs against a disposable MySQL/MariaDB or PostgreSQL database when diff --git a/tests/standalone/fixtures/checkout-harness.php b/tests/standalone/fixtures/checkout-harness.php index e6dbdb5..29019ac 100644 --- a/tests/standalone/fixtures/checkout-harness.php +++ b/tests/standalone/fixtures/checkout-harness.php @@ -49,6 +49,8 @@ final class FakeProduct public ?int $quantity_tolerance = null; public ?string $quantity_rounding = null; public int $weight = 125; + /** Publication flag of real products; null = an object without one, which counts as published. */ + public ?int $published = null; public function __construct(public int $id, public string $sku, public float $price = 10.0, public int $mode = sProduct::MODE_SIMPLE, public ?string $gtin = null, public int $inventory = 100, public string $title = '') @@ -135,12 +137,16 @@ public function __call($level, $arguments) { $this->records[] = [$level, $argume public array $products = []; /** @var list, 1: string}> */ public array $mails = []; + public string $currency = 'EUR'; + /** @var array Exchange rates as `FROM_TO`; unknown pairs convert 1:1. */ + public array $rates = []; public function config($key, $default = null) { return array_key_exists($key, $this->config) ? $this->config[$key] : (str_starts_with($key, 'notifications.') ? 0 : $default); } public function getProduct($id, $lang = null) { return $this->products[(int)$id] ?? null; } public function getProducts($ids) { return collect(array_values(array_intersect_key($this->products, array_flip($ids)))); } - public function currentCurrency() { return 'EUR'; } - public function convertPrice($value, $currency = null, $display = null) { return number_format((float)$value, 2, '.', '') . ' EUR'; } - public function convertPriceNumber($value, $from = null, $to = null) { return (float)$value; } + public function currentCurrency() { return $this->currency; } + public function getCurrencyFormat($currency) { return ['exp' => 2, 'decimals' => '.', 'thousands' => ' ', 'show' => 1, 'symbol' => strtoupper("$currency") . ' ', 'position' => 'before']; } + public function convertPrice($value, $currency = null, $display = null) { return number_format((float)$value, 2, '.', '') . ' ' . $this->currency; } + public function convertPriceNumber($value, $from = null, $to = null) { return (float)$value * ($this->rates[strtoupper("$from") . '_' . strtoupper("$to")] ?? 1.0); } public function resolveProductPricing($product, $currency = null) { return app(sPriceResolver::class)->resolve($product, $currency); } public function adminNotificationEmails() { return ['admin@shop.test']; } public function notifyEmail($to, $template, $data = []) { $this->mails[] = [array_values((array)$to), $template]; } @@ -163,6 +169,11 @@ public function resolve(object $product, ?string $currency = null, string $price public function generate(?int $orderId = null): string { return ''; } }); $app->instance('db.transactions', new DatabaseTransactionsManager()); +// In-memory cache store for the storefront order limit. +$app->instance('cache', new class { + private ?Illuminate\Cache\Repository $store = null; + public function store($name = null) { return $this->store ??= new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore()); } +}); $db = new Capsule($app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'foreign_key_constraints' => true]); $db->setEventDispatcher(new Dispatcher($app)); diff --git a/tests/standalone/fixtures/piece-cart-master.json b/tests/standalone/fixtures/piece-cart-master.json index 330a249..df42192 100644 --- a/tests/standalone/fixtures/piece-cart-master.json +++ b/tests/standalone/fixtures/piece-cart-master.json @@ -27,11 +27,6 @@ "priceMode": "auto", "priceAsFloat": 12.5, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "1:0", "product_id": 1, "option_id": 0, @@ -81,11 +76,6 @@ "priceMode": "auto", "priceAsFloat": 3.335, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "2:0", "product_id": 2, "option_id": 0, @@ -135,11 +125,6 @@ "priceMode": "auto", "priceAsFloat": 8.335, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "2:5", "product_id": 2, "option_id": 5, @@ -189,11 +174,6 @@ "priceMode": "auto", "priceAsFloat": 0.1, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "3:0", "product_id": 3, "option_id": 0, @@ -246,11 +226,6 @@ "priceMode": "auto", "priceAsFloat": 12.5, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "1:0", "product_id": 1, "option_id": 0, @@ -277,11 +252,6 @@ "priceMode": "auto", "priceAsFloat": 3.335, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "2:0", "product_id": 2, "option_id": 0, @@ -308,11 +278,6 @@ "priceMode": "auto", "priceAsFloat": 8.335, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "2:5", "product_id": 2, "option_id": 5, @@ -339,11 +304,6 @@ "priceMode": "auto", "priceAsFloat": 0.1, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "3:0", "product_id": 3, "option_id": 0, diff --git a/tests/standalone/scommerce-actual-quantities.php b/tests/standalone/scommerce-actual-quantities.php index 1c3a98f..fbe80b1 100644 --- a/tests/standalone/scommerce-actual-quantities.php +++ b/tests/standalone/scommerce-actual-quantities.php @@ -86,7 +86,8 @@ function config($key = null, $default = null) { return app('config')->get($key, $check($line['actual_quantity_label'] === '2.553 m' && $line['quantity_label'] === '2.53 m' && $line['sumAsFloat'] === 3.06 && $line['line_weight'] === 319 && $line['retail_excise_amount'] === Seiger\sCommerce\Services\VatDisplay::excise(3.06, 5), 'Sum, excise and weight follow the actual quantity'); $check((float)$updated->cost === 3.06 + 25.0 + 5.0, 'Cost follows; the delivery is kept'); -$entry = end($updated->history); +$history = $updated->history; +$entry = end($history); $check(($entry['actual_quantity_set'] ?? null) === ['line_key' => '10:0', 'old' => null, 'new' => 2553, 'ordered' => 2530, 'source' => 'manager', 'over_tolerance' => false] && $entry['user_id'] === 7, 'History entry with the old and new values'); $orderUpdates = array_values(array_filter($events, static fn ($event) => $event[0] === 'sCommerce.OrderUpdated')); @@ -97,7 +98,7 @@ function config($key = null, $default = null) { return app('config')->get($key, $check($service->set($order, '10:0', 2656, 'manager', 7, 'op-2')->products[0]['actual_quantity'] === 2656, 'Up to the tolerance'); $check($refused(fn () => $service->set($order, '10:0', 2657, 'manager', 7, 'op-3'), 'actual_quantity_tolerance_exceeded'), 'Above the tolerance is refused'); $over = $service->set($order, '10:0', 2800, 'manager', 7, 'op-4', true); -$check($over->products[0]['actual_quantity'] === 2800 && end($over->history)['actual_quantity_set']['over_tolerance'] === true, 'Allowed when the customer agreed'); +$check($over->products[0]['actual_quantity'] === 2800 && ($over->history[array_key_last($over->history)]['actual_quantity_set']['over_tolerance'] ?? null) === true, 'Allowed when the customer agreed'); $check($service->set($order, '10:0', 1200, 'manager', 7, 'op-5')->products[0]['actual_quantity'] === 1200, 'Below is always allowed'); // 3. Idempotency by operation key. diff --git a/tests/standalone/scommerce-checkout-validation.php b/tests/standalone/scommerce-checkout-validation.php index 482a440..e177c24 100644 --- a/tests/standalone/scommerce-checkout-validation.php +++ b/tests/standalone/scommerce-checkout-validation.php @@ -66,6 +66,8 @@ $check($result['success'] && count($seen) === $seenBefore + 1, 'Valid string quantity is validated once'); $check($result['order']->products[0]['quantity'] === 3 && (float) $result['order']->cost === 30.0, 'Accepted quantity becomes integer before totals and persistence'); $_SESSION['sCart'] = [1 => [0 => 3]]; +// Product quick orders resolve the cart facade for stock checks; a request starts with the session cart. +Illuminate\Support\Facades\Facade::clearResolvedInstance('sCart'); $seenBefore = count($seen); $result = sCheckout::quickOrder(['productId' => 0, 'phone' => '123456789']); $check($result['success'] && count($seen) === $seenBefore + 1, 'Cart quick checkout also validates once'); diff --git a/tests/standalone/scommerce-container-deposit.php b/tests/standalone/scommerce-container-deposit.php index 071496a..7bd80c7 100644 --- a/tests/standalone/scommerce-container-deposit.php +++ b/tests/standalone/scommerce-container-deposit.php @@ -65,6 +65,14 @@ $first = sOrder::query()->find($result['order']->id); $check($result['success'] && (float)$first->cost === 370.0 && $first->products[0]['deposit_amount'] === 250 && $first->products[0]['deposit_returnable'] === true, 'First purchase pays 370'); +// Customer declarations waive the deposit unseen: off unless the shop allows them. +$_SESSION['sCart'] = [10 => [0 => ['quantity' => 1, 'exchange' => 1]]]; +$check((new sCart())->setContainerExchange(10, 0, 1)['success'] === false && (new sCart())->getMiniCart()['totalSum'] === 370.0 + && sCheckout::quickOrder(['productId' => 10, 'quantity' => 1, 'exchange' => 1, 'email' => 'quick@shop.test'])['order']->products[0]['deposit_amount'] === 250, + 'Without container_exchange_on, declared exchanges are ignored'); +$check(str_contains(file_get_contents($package . '/src/Http/routes.php'), 'ContainerDeposit::exchangeEnabled()'), 'The exchange route needs container_exchange_on'); +$shop->config['product.container_exchange_on'] = 1; + // Next purchase with an exchange: no new deposit. $_SESSION['sCart'] = [10 => [0 => 1]]; $exchange = (new sCart())->setContainerExchange(10, 0, 5); diff --git a/tests/standalone/scommerce-currency-settings.mjs b/tests/standalone/scommerce-currency-settings.mjs new file mode 100644 index 0000000..25519bb --- /dev/null +++ b/tests/standalone/scommerce-currency-settings.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +// Currency cards in the settings: cyclic up/down buttons, header drag in both directions, +// no duplicate currencies and no debug output. Runs the real partial script on a tiny fake DOM. +const read = path => fs.readFileSync(new URL(path, import.meta.url), 'utf8'); +const partial = read('../../views/partials/settingsCurrencyPriceConfiguration.blade.php'); +const item = read('../../views/partials/settingsCurrencyItem.blade.php'); +const script = partial.match(/