diff --git a/README.md b/README.md index 62bda44..7ac6aad 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ of Evolution CMS for seamless and efficient **online commerce**. - [x] sCommerce.ResolveCheckoutContext. - [x] Shipments and Tracking (ERP/carrier API, partial parcels, optional order status from shipments). - [x] sCommerce.ShipmentUpdated. +- [x] Measured Quantities (sell by weight, length, volume or count in whole base units, price per unit or per 100 g / 1,000 items, actual quantities of cut or weighed goods). - [x] Stock Integration (external stock provider, stock write API, optional stock reduction on orders). - [x] sCommerce.ResolveProductStock. - [x] sCommerce.ProductStockChanged. diff --git a/config/sCommerceSettings.php b/config/sCommerceSettings.php index eb51b2b..f711df2 100644 --- a/config/sCommerceSettings.php +++ b/config/sCommerceSettings.php @@ -58,6 +58,22 @@ "inventory_on" => 2, "inventory_release_on_cancel" => 1, "link_rule" => "category", + "measures" => [ + "weight" => ["price_unit" => "kg", "price_per" => 1, "step" => 1, "min" => 1, "max" => 1000000, "tolerance" => 0, "input_decimals" => 3], + "length" => ["price_unit" => "m", "price_per" => 1, "step" => 10, "min" => 10, "max" => 1000000, "tolerance" => 0, "input_decimals" => 2], + "volume" => ["price_unit" => "l", "price_per" => 1, "step" => 1, "min" => 1, "max" => 1000000, "tolerance" => 0, "input_decimals" => 3], + "count" => ["price_unit" => "unit", "price_per" => 1000, "step" => 1, "min" => 1, "max" => 1000000000, "tolerance" => 0, "input_decimals" => 0], + ], + "quantity_rounding" => [ + "kg" => "nearest", + "g" => "nearest", + "m" => "up", + "cm" => "up", + "mm" => "up", + "l" => "up", + "ml" => "up", + "unit" => "up", + ], "rating_on" => 1, "retail_excise_on" => 0, "retail_excise_rate" => 0, diff --git a/database/migrations/2026_10_27_000000_add_measured_quantities_to_products.php b/database/migrations/2026_10_27_000000_add_measured_quantities_to_products.php new file mode 100644 index 0000000..d7b57fd --- /dev/null +++ b/database/migrations/2026_10_27_000000_add_measured_quantities_to_products.php @@ -0,0 +1,49 @@ + */ + private function columns(): array + { + return [ + 'measure' => static fn (Blueprint $table) => $table->unsignedTinyInteger('measure')->default(0)->comment('0 piece, 1 weight, 2 length, 3 volume, 4 count'), + 'price_unit' => static fn (Blueprint $table) => $table->string('price_unit', 4)->nullable()->comment('Unit the price refers to, e.g. kg or m; NULL = site default'), + 'price_per' => static fn (Blueprint $table) => $table->unsignedInteger('price_per')->nullable()->comment('Price basis in price units, e.g. 100 for a price per 100 g; NULL = site default'), + 'quantity_step' => static fn (Blueprint $table) => $table->unsignedInteger('quantity_step')->nullable()->comment('Orderable step in base units; NULL = site default'), + 'quantity_min' => static fn (Blueprint $table) => $table->unsignedInteger('quantity_min')->nullable()->comment('Minimum order quantity in base units; NULL = site default'), + 'quantity_tolerance' => static fn (Blueprint $table) => $table->unsignedSmallInteger('quantity_tolerance')->nullable()->comment('Allowed actual-quantity overrun in basis points; NULL = site default'), + 'quantity_rounding' => static fn (Blueprint $table) => $table->string('quantity_rounding', 8)->nullable()->comment('up, nearest or down; NULL = site setting of the price unit'), + ]; + } + + public function up(): void + { + foreach ($this->columns() as $column => $definition) { + if (!Schema::hasColumn('s_products', $column)) { + Schema::table('s_products', static function (Blueprint $table) use ($definition) { + $definition($table); + }); + } + } + } + + public function down(): void + { + foreach (array_reverse(array_keys($this->columns())) as $column) { + if (Schema::hasColumn('s_products', $column)) { + Schema::table('s_products', static function (Blueprint $table) use ($column) { + $table->dropColumn($column); + }); + } + } + } +}; diff --git a/database/migrations/2026_10_27_000001_add_scommerce_actual_quantity_permission.php b/database/migrations/2026_10_27_000001_add_scommerce_actual_quantity_permission.php new file mode 100644 index 0000000..74c3689 --- /dev/null +++ b/database/migrations/2026_10_27_000001_add_scommerce_actual_quantity_permission.php @@ -0,0 +1,138 @@ + Permission key => English name. */ + private const PERMISSIONS = [ + 'scommerce_order_actual_over_tolerance' => 'sCommerce: exceed the actual-quantity tolerance', + ]; + + public function up(): void + { + if (!Schema::hasTable('permissions_groups') || !Schema::hasTable('permissions')) { + return; + } + + $groupId = $this->getOrCreateGroup(); + foreach (self::PERMISSIONS as $key => $name) { + $this->upsertPermission($groupId, $key, $name); + $this->assignPermissionToAdmin($key); + } + } + + public function down(): void + { + $keys = array_keys(self::PERMISSIONS); + if (Schema::hasTable('role_permissions')) { + DB::table('role_permissions')->whereIn('permission', $keys)->delete(); + } + if (Schema::hasTable('permissions')) { + DB::table('permissions')->whereIn('key', $keys)->delete(); + } + if (Schema::hasTable('permissions_groups')) { + $group = DB::table('permissions_groups')->where('name', 'Seiger packages')->first(); + if ($group && !(Schema::hasTable('permissions') && DB::table('permissions')->where('group_id', $group->id)->exists())) { + DB::table('permissions_groups')->where('id', $group->id)->delete(); + } + } + } + + protected function getOrCreateGroup(): int + { + $group = DB::table('permissions_groups')->where('name', 'Seiger packages')->first(); + if ($group) { + return $group->id; + } + + $row = ['name' => 'Seiger packages', 'lang_key' => 'seiger_packages', 'created_at' => now(), 'updated_at' => now()]; + try { + return DB::table('permissions_groups')->insertGetId($row); + } catch (QueryException $e) { + $this->fixPostgresSequence('permissions_groups'); + try { + return DB::table('permissions_groups')->insertGetId($row); + } catch (QueryException $e2) { + $group = DB::table('permissions_groups')->where('name', 'Seiger packages')->first(); + if ($group) { + return $group->id; + } + throw $e2; + } + } + } + + protected function upsertPermission(int $groupId, string $key, string $name): void + { + $values = [ + 'name' => $name, + 'lang_key' => 'sCommerce::global.permission_' . substr($key, strlen('scommerce_')), + 'group_id' => $groupId, + 'disabled' => 0, + 'updated_at' => now(), + ]; + + if (DB::table('permissions')->where('key', $key)->exists()) { + DB::table('permissions')->where('key', $key)->update($values); + return; + } + + try { + DB::table('permissions')->insert(['key' => $key, 'created_at' => now()] + $values); + } catch (QueryException $e) { + $this->fixPostgresSequence('permissions'); + if (!DB::table('permissions')->where('key', $key)->exists()) { + DB::table('permissions')->insert(['key' => $key, 'created_at' => now()] + $values); + } + DB::table('permissions')->where('key', $key)->update($values); + } + } + + protected function assignPermissionToAdmin(string $key): void + { + if (!Schema::hasTable('role_permissions') + || DB::table('role_permissions')->where('role_id', 1)->where('permission', $key)->exists()) { + return; + } + + try { + DB::table('role_permissions')->insert([ + 'role_id' => 1, + 'permission' => $key, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } catch (QueryException $e) { + // Inserted concurrently. + } + } + + protected function fixPostgresSequence(string $table): void + { + try { + $fullTable = DB::getTablePrefix() . $table; + $maxId = DB::table($table)->max('id') ?? 0; + DB::statement("SELECT setval(pg_get_serial_sequence('{$fullTable}', 'id'), " . ($maxId + 1) . ", false)"); + } catch (\Exception $e) { + // Not PostgreSQL, or no permission to change the sequence. + } + } +}; diff --git a/docs/en/configuration.md b/docs/en/configuration.md index 86648ff..e75b966 100644 --- a/docs/en/configuration.md +++ b/docs/en/configuration.md @@ -13,7 +13,7 @@ Since 1.5.0, orders can reduce product stock. The settings are on the product se - Each order reduces stock once. All its lines change or none: when a product has less stock than the order needs, nothing changes and the order is retried later. Stock never becomes negative. - Lines that do not track inventory (virtual and service products) and orders of trusted test users never change stock. -- A returned order gets back exactly the quantities it took, even if its lines were edited afterwards. An order that leaves the cancelled status reduces stock again. +- When a manager edits the products of an order that reduced stock, stock follows the edit (changed, added and removed lines). A returned order gets back exactly the quantities it holds at that time. An order that leaves the cancelled status reduces stock again. - `s_orders.inventory_committed_at` marks orders whose stock is reduced; `meta.inventory` keeps the reduced quantities. - The reduction runs after the order is saved, independently of event listeners. The hidden sTask worker **Stock reconciliation** retries failed reductions and returns every 15 minutes. - When an ERP is the stock source and imports quantities that already exclude open orders, keep `none`; otherwise open orders are subtracted twice. A bound stock provider can also reduce and return stock in the ERP itself; see [Stock events](developers/events.md#stock-events). diff --git a/docs/en/developers/deliveries.md b/docs/en/developers/deliveries.md index 48402af..dcc62b1 100644 --- a/docs/en/developers/deliveries.md +++ b/docs/en/developers/deliveries.md @@ -343,6 +343,7 @@ Rules: - Reaching `shipped`, `in_transit` or `delivered` without a date sets `shipped_at` to now; `delivered` also sets `delivered_at`. - **Items** refer to order lines by `line` (the index in `s_orders.products`) and/or `line_key`. `product_id` and `sku` are optional and checked against the line. Quantities are positive integers; the same line listed twice is summed. Lines with `requires_shipping = false` (services) cannot be shipped. `items = null` ships the whole order and cannot be combined with other active shipments of the order. - The shipped quantities of active shipments (all except `failed`, `returned` and `canceled`) cannot exceed the ordered quantities. +- Lines of products sold by measure are shipped in base units (grams, millimetres, millilitres). When an actual quantity is set, it replaces the ordered quantity for coverage. Use the line's `line_weight` (grams for the whole line) instead of `weight × quantity`; see [Measured quantities](measured-quantities.md#order-lines). - Canceling a sent parcel is a correction: its quantities are released, `metadata.voided_after` records the previous status, a warning is logged, and the order status is not moved back. - Errors: `InvalidArgumentException` for invalid data, `Seiger\sCommerce\Shipping\ShipmentConflictException` when the reference belongs to another order or the quantities exceed or overlap the order. - Writes lock the order row. A concurrent insert of the same reference is detected and applied to the existing shipment. diff --git a/docs/en/developers/events.md b/docs/en/developers/events.md index 54078e1..a48e5c1 100644 --- a/docs/en/developers/events.md +++ b/docs/en/developers/events.md @@ -560,6 +560,12 @@ Since 1.5.0, a change of `s_products.inventory` or `s_products.availability` dis The payload contains `event`, `productId`, `modificationId` (always `0`), `sku`, `oldQuantity`, `newQuantity`, `oldAvailability`, `newAvailability`, `stockSource` (the writer: `manager`, `import`, `order`, the source passed to `StockWriter`, or the event scope source for other model saves) and the context keys `source`, `actorId`, `correlationId`, `causationId`, `integrationActor`, `eventId` and `occurredAt`. Stock reductions and returns of orders run in the event scope source `inventory`. +For products sold by weight, length, volume or count, all stock quantities (inventory, events, commits and the provider's `available()`) are base units: grams, millimetres, millilitres or items. See [Measured quantities](measured-quantities.md#stock-and-shipments). + +### Stock follows order edits + +Since 1.5.0, when a manager changes the products of an order whose stock sCommerce reduced locally, the stock follows in the same request: `StockCommitter::adjust($order)` takes or returns the difference for changed, added and removed lines, for piece and measured products alike. Actual quantities of measured lines adjust stock the same way. When a product has too little stock for an increase, the edit is not saved. Orders committed by a stock provider are not adjusted locally; the provider sees `sCommerce.OrderUpdated`. A later return restores exactly the adjusted quantities. Before 1.5.0 manager edits did not change reduced stock. + `sCommerce::supportsIntegration('stock-provider')` reports support. ## Additional manager and frontend events diff --git a/docs/en/developers/integration.md b/docs/en/developers/integration.md index 5811cd0..061fb8a 100644 --- a/docs/en/developers/integration.md +++ b/docs/en/developers/integration.md @@ -303,6 +303,7 @@ Since 1.5.0, every new order line (storefront cart, quick order, manager editor | `tracks_inventory` | bool | the line counts against inventory | | `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) | 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/measured-quantities.md b/docs/en/developers/measured-quantities.md new file mode 100644 index 0000000..6824037 --- /dev/null +++ b/docs/en/developers/measured-quantities.md @@ -0,0 +1,115 @@ +# Measured quantities + +Since 1.5.0, a product can be sold by weight, length, volume or count instead of by the piece: cable by the metre, paint by the litre, sand by the kilogram, ad impressions per thousand. Quantities stay integers. A measured quantity is a whole number of **base units**: + +| Measure | Base unit | Price units | Price basis | +|---|---|---|---| +| `weight` | gram (`g`) | `kg`, `g` | 1, 10, 100 or 1000 price units, at most 1 kg | +| `length` | millimetre (`mm`) | `m`, `cm`, `mm` | 1, 10, 100 or 1000 price units, at most 1 m | +| `volume` | millilitre (`ml`) | `l`, `ml` | 1, 10, 100 or 1000 price units, at most 1 l | +| `count` | item | — | per 1, 1,000 or 1,000,000 items | + +The price refers to `factor` base units: base units per price unit × price basis. `€1.20 / m` has factor 1000, `€2.49 / 100 g` has factor 100, `€2.50 / 1,000` impressions has factor 1000. A line total is `round_half_up(price × quantity ÷ factor)` in cents, computed in integers from the unrounded price (`price_regular` has five decimals). The unit price is never rounded to cents first. + +Piece products, old orders and themes that post integer quantities behave exactly as before. Run the sCommerce migrations to add the product columns. + +## Products + +The product form has a **Sold by** block with the measure, the price unit, the price basis, rounding, step, minimum and tolerance. Empty values use the site defaults. The stock field shows the base unit (**Quantity, mm**): stock of a measured product is in base units. + +| Column on `s_products` | Meaning | +|---|---| +| `measure` | `0` piece, `1` weight, `2` length, `3` volume, `4` count | +| `price_unit` | unit code the price refers to (`kg`, `m`, …); `NULL` = site default | +| `price_per` | price basis in price units (`100` for a price per 100 g); `NULL` = site default | +| `quantity_step` | orderable step in base units (`10` = cut to the centimetre); `NULL` = site default | +| `quantity_min` | minimum order in base units, at least one step; `NULL` = site default | +| `quantity_tolerance` | allowed overrun of an actual quantity in basis points (`500` = 5 %); `NULL` = site default | +| `quantity_rounding` | storefront rounding to the step: `up`, `nearest` or `down`; `NULL` = site setting of the price unit | + +Changing the measure of a product that has stock or appears in an order is refused, because it would change the meaning of the stored integers. Measured products cannot have a returnable container deposit. Physical attributes of a measured product describe **one price unit** (a cable's grams per metre). `count` products are normally services without shipping and stock; if they track stock, their maximum quantity is 2,000,000,000. + +The CSV import and export have the columns `measure` (`piece`, `weight`, `length`, `volume`, `count`), `price_unit`, `price_per`, `quantity_step`, `quantity_min`, `quantity_tolerance` and `quantity_rounding`. Missing columns keep the product's values; invalid values fail the row. + +## Settings + +The product settings tab has **Measured quantities**: per measure the default price unit, price basis, step, minimum, maximum (base units), tolerance and input decimals (`product.measures`), and **Rounding to the step per unit** (`product.quantity_rounding`). Defaults: `up` for length and volume (too short is useless), `nearest` for weight; a unit without a setting rounds up. Invalid values are not saved and the previous ones are kept. Changing the defaults never changes stored order lines. + +## Storefront + +`sCart::addProduct()` keeps its signature. For measured products, an `amount` field in the price unit (`2.53` metres, always with a `.` separator) takes precedence over `quantity` (base units). The quantity is raised to the minimum, rounded to the step with the product's rounding, capped at the maximum and at the available stock rounded **down** to a step. When the quantity changes, the response contains `quantityAdjusted: true` and the message says so. The default quantity is the minimum. + +```blade +{{-- Amount input in the price unit; piece products get the usual quantity input. --}} +
+ {!! sCommerce::quantityInput($product) !!} + +
+{{ sCommerce::unitPrice($product) }} {{-- €2.49 / 100 g --}} + +{{-- In the cart: --}} +{!! sCommerce::quantityInput($product, ['data-sc-quantity' => $product->id, 'value' => $amount]) !!} +``` + +`quantityInput()` renders `` with `min`, `step` and `max` in the price unit and `data-sc-measure`, `data-sc-factor` (base units per price unit), `data-sc-step`, `data-sc-min` (base units), `data-sc-decimals` and `data-sc-unit`. The supplied script posts the `amount` of such inputs; quick orders (`data-sc-fast-buy`) send it with the other inputs. + +Labels follow the language: units come from the language file (`kg` is `кг` in Ukrainian) and so do the decimal and thousands separators (`2,53 м`). Use `MeasuredQuantity::label($quantity, $rules, $locale)` and `MeasuredQuantity::unitPriceLabel($formattedPrice, $rules, $locale)`. + +## Order lines + +Measured lines carry these keys next to the usual ones; piece lines have none of them: + +| Key | Example | Notes | +|---|---|---| +| `quantity` | `2530` | integer, base units | +| `measure` | `"length"` | `weight`, `length`, `volume` or `count` | +| `base_unit` | `"mm"` | `g`, `mm`, `ml` or `unit` | +| `price_unit` | `"m"` | unit the price refers to | +| `price_per` | `1` | price basis (`100` for `€2.49 / 100 g`) | +| `factor` | `1000` | base units the price refers to, a power of ten | +| `quantity_rounding`, `quantity_step`, `quantity_min`, `quantity_tolerance` | `"up"`, `10`, `1000`, `500` | rules at checkout | +| `quantity_label`, `unit_price_label` | `"2.53 m"`, `"€1.20 / m"` | display only, in the order language | +| `line_weight` | `475` | grams for the whole line, when known | +| `actual_quantity`, `actual_quantity_at`, `actual_quantity_by`, `actual_quantity_source`, `actual_quantity_label` | `2553` | only when an actual quantity is set | + +Rules for consumers: + +- The billable quantity is `actual_quantity ?? quantity`: `MeasuredQuantity::effective($line)`. +- The rules of a stored line come from the line, never from the current product: `MeasuredQuantity::fromLine($line)`. +- Never send labels as data. For accounting, use `MeasuredQuantity::forAccounting($quantity, $price, $rules)`: weight, length and volume are expressed in the largest unit (`0.330 kg × 24.90`, `2.530 m × 1.20`; UN/ECE Rec 20 `KGM`, `MTR`, `LTR`), and `count` in basis units (`1.234567 × 1,000,000 tokens @ 0.50`). Both are exact; the accounting line total equals the sCommerce line total. The accounting system must accept 3 quantity decimals, and up to 6 for `count`. +- Payment providers that need integer quantities send a measured line as quantity 1 with the exact line total and a name such as `2.53 m × €1.20`. +- Delivery integrations should prefer `line_weight` over `weight × quantity`. + +The manager order form shows measured quantities in the price unit and the manager's language. Rule and actual-quantity keys of existing lines are taken from the stored line on save; lines added in the manager take them from the product. Quantities are checked strictly (minimum, step, maximum) and never rounded. Repeat orders apply the current rules of the product and refuse a product whose measure has changed. + +## Actual quantities + +Cut or weighed goods often differ from the ordered amount: 2530 mm ordered, 2553 mm cut. `Seiger\sCommerce\Services\OrderActualQuantity::set()` records the actual quantity of a measured line: + +```php +use Seiger\sCommerce\Services\OrderActualQuantity; + +$order = (new OrderActualQuantity())->set($order, '12:0', 2553, 'erp', 0, 'cut-REF-2026-0042'); +``` + +- Arguments: order, `line_key`, actual quantity in base units, source (`manager`, `erp` or `api`), actor id, an idempotency key and `overTolerance`. +- The same key with the same value returns the order unchanged; with another value it is a conflict (409). The last 50 keys are kept in `meta.actual_quantity_ops`. +- There is no step check: the actual quantity is a measurement. It must be at least 1 and at most the line maximum. +- It may exceed the ordered quantity by the line's `quantity_tolerance`. More needs `overTolerance = true`, meaning the customer agreed; in the manager this needs the permission `scommerce_order_actual_over_tolerance`. +- Cancelled and deleted orders, piece lines and lines already in a shipped or delivered shipment are refused (a correction after dispatch is a return or refund). +- The line sum, retail excise, line weight, order cost and history are updated in one transaction, with one `sCommerce.OrderUpdated` in the event scope of the source. Locally committed stock follows the change. +- Refusals throw `MeasuredQuantityException` with `messageKey` and an HTTP `status`; a stock shortage throws `StockShortageException`. + +In the manager, measured lines have an **Actual** input on the order screen. + +## Stock and shipments + +Stock, stock events, `StockWriter`, `StockProviderInterface` and shipments keep their signatures; for measured products their integers are base units. Stock reduction and shipment coverage use the billable quantity, so a longer cut needs a longer shipment and takes more stock. `StockCommitter::adjust($order)` applies changed, added and removed lines of a locally committed order to stock; it runs after manager edits and actual quantities. Provider-committed orders are left to the provider, which receives `sCommerce.OrderUpdated`. + +## Payments + +`sOrder::authorizationCeiling()` returns the cost plus, for every measured line with a tolerance, the line total times the tolerance. Methods that authorize and capture later may reserve up to this amount; existing methods are unchanged. After an actual quantity changes the cost, capture the new amount, collect the difference with the final payment link or refund an overpayment, as for any cost change. + +## Detecting support + +`sCommerce::supportsIntegration('measured-quantities')` and `sCommerce::supportsIntegration('actual-quantities')` report support. `INTEGRATION_EVENTS_VERSION` stays `1`. diff --git a/docs/en/developers/payments/index.md b/docs/en/developers/payments/index.md index 47ecd28..76af651 100644 --- a/docs/en/developers/payments/index.md +++ b/docs/en/developers/payments/index.md @@ -1021,6 +1021,10 @@ For goods whose final price is known only after weighing, authorize an estimate A capture reported first by the provider's webhook is recorded the same way: `PaymentFlow::record()` with status `captured`, the authorization's `providerRef` and a lower amount turns the `authorized` row into a partial capture. A later replay of the original authorization is ignored. +### Measured goods: authorization ceiling and actual quantities + +Goods sold by length or weight may be cut or weighed above the ordered amount, within the line's tolerance (see [Measured quantities](../measured-quantities.md#actual-quantities)). `sOrder::authorizationCeiling()` returns the order cost plus, for every measured line with a tolerance, the line total times the tolerance, rounded half up to cents. A method that authorizes and captures later may authorize up to this ceiling; this is opt-in, and existing methods keep their amounts. When an actual quantity changes the cost, capture the new amount, collect the difference with the final item of the payment schedule, or refund an overpayment. + ### Recording offline payments Cash and bank transfers are not moved by a provider, so the manager records them. For an order paid with an offline method (one implementing `OfflineSettlementPaymentMethodInterface`, such as **Cash** and **Bank invoice**), the panel shows: diff --git a/docs/ru/configuration.md b/docs/ru/configuration.md index 9b3ee6c..b9942d6 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -13,7 +13,7 @@ sCommerce не требует настройки для старых цен. У - Каждый заказ списывает остаток один раз. Меняются все его строки или ни одна: если остаток товара меньше, чем нужно заказу, ничего не меняется, и заказ обрабатывается повторно позже. Остаток никогда не становится отрицательным. - Строки, которые не учитываются в остатках (виртуальные товары и услуги), и заказы доверенных тестовых пользователей никогда не меняют остатки. -- Возвращённый заказ получает обратно ровно то количество, которое списал, даже если его строки потом редактировались. Заказ, который выходит из статуса отмены, снова списывает остаток. +- Когда менеджер редактирует товары заказа, который списал остаток, остаток меняется в соответствии с редактированием (изменённые, добавленные и удалённые строки). Возвращённый заказ получает обратно ровно то количество, которое за ним числится на этот момент. Заказ, который выходит из статуса отмены, снова списывает остаток. - `s_orders.inventory_committed_at` отмечает заказы, остаток которых списан; `meta.inventory` хранит списанные количества. - Списание выполняется после сохранения заказа, независимо от слушателей событий. Скрытый воркер sTask **Сверка остатков** каждые 15 минут повторяет неудавшиеся списания и возвраты. - Если источником остатков является ERP и она импортирует количества, из которых уже вычтены открытые заказы, оставьте `none`; иначе открытые заказы будут вычтены дважды. Привязанный провайдер остатков также может списывать и возвращать остатки в самой ERP; см. [События остатков](developers/events.md#события-остатков). diff --git a/docs/ru/developers/deliveries.md b/docs/ru/developers/deliveries.md index 0700fbe..014372a 100644 --- a/docs/ru/developers/deliveries.md +++ b/docs/ru/developers/deliveries.md @@ -343,6 +343,7 @@ $shipments->setStatus($shipment, 'delivered', new DateTimeImmutable('2026-10-21 - Если отправление достигает `shipped`, `in_transit` или `delivered` без даты, `shipped_at` устанавливается на текущее время; `delivered` также устанавливает `delivered_at`. - **Позиции** ссылаются на строки заказа через `line` (индекс в `s_orders.products`) и/или `line_key`. `product_id` и `sku` необязательны и проверяются на соответствие строке. Количества — положительные целые числа; одна и та же строка, указанная дважды, суммируется. Строки с `requires_shipping = false` (услуги) нельзя отправить. `items = null` отправляет весь заказ и не может сочетаться с другими активными отправлениями заказа. - Отправленные количества активных отправлений (всех, кроме `failed`, `returned` и `canceled`) не могут превышать заказанные количества. +- Строки измеряемых товаров отправляются в базовых единицах (граммах, миллиметрах, миллилитрах). Если задано фактическое количество, для покрытия оно заменяет заказанное. Используйте `line_weight` строки (граммы для всей строки) вместо `weight × quantity`; см. [Измеряемые количества](measured-quantities.md#строки-заказа). - Отмена отправленной посылки является исправлением: её количества освобождаются, `metadata.voided_after` сохраняет предыдущий статус, записывается предупреждение, а статус заказа назад не возвращается. - Ошибки: `InvalidArgumentException` для некорректных данных, `Seiger\sCommerce\Shipping\ShipmentConflictException`, когда ссылка принадлежит другому заказу или количества превышают заказанные либо пересекаются с другими отправлениями заказа. - Записи блокируют строку заказа. Одновременная вставка той же ссылки обнаруживается и применяется к существующему отправлению. diff --git a/docs/ru/developers/events.md b/docs/ru/developers/events.md index fad03d9..c1f7d54 100644 --- a/docs/ru/developers/events.md +++ b/docs/ru/developers/events.md @@ -654,6 +654,12 @@ Event::listen('sCommerce.ResolveProductStock', function (array $payload) { Payload содержит `event`, `productId`, `modificationId` (всегда `0`), `sku`, `oldQuantity`, `newQuantity`, `oldAvailability`, `newAvailability`, `stockSource` (кто записал: `manager`, `import`, `order`, источник, переданный в `StockWriter`, или источник области событий для других сохранений модели) и ключи контекста `source`, `actorId`, `correlationId`, `causationId`, `integrationActor`, `eventId` и `occurredAt`. Списания и возвраты остатков по заказам выполняются в области событий с источником `inventory`. +Для товаров, продаваемых по весу, длине, объёму или количеству, все количества остатка (остаток, события, списания и `available()` провайдера) указываются в базовых единицах: граммах, миллиметрах, миллилитрах или штуках. См. [Измеряемые количества](measured-quantities.md#остатки-и-отправления). + +### Остаток следует за редактированием заказов + +Начиная с 1.5.0, когда менеджер меняет товары заказа, остаток которого sCommerce списал локально, остаток меняется в том же запросе: `StockCommitter::adjust($order)` списывает или возвращает разницу для изменённых, добавленных и удалённых строк — как для поштучных, так и для измеряемых товаров. Фактические количества измеряемых строк корректируют остаток так же. Если для увеличения не хватает остатка товара, редактирование не сохраняется. Заказы, списанные провайдером остатков, локально не корректируются; провайдер получает `sCommerce.OrderUpdated`. Последующий возврат восстанавливает ровно скорректированные количества. До 1.5.0 редактирование в менеджере не меняло списанный остаток. + `sCommerce::supportsIntegration('stock-provider')` сообщает о поддержке. ## Дополнительные события менеджера и frontend diff --git a/docs/ru/developers/integration.md b/docs/ru/developers/integration.md index cf1a621..01723a2 100644 --- a/docs/ru/developers/integration.md +++ b/docs/ru/developers/integration.md @@ -302,6 +302,7 @@ class SCommerceProductController extends BaseController | `tracks_inventory` | bool | строка учитывается в остатках | | `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#строки-заказа) | Налоговые слушатели также могут добавлять `tax_code`, `unit_price_gross`, `tax_amount` и `prices_include_tax`. См. [События](events.md#строки-заказа-исполнение-и-налоги). diff --git a/docs/ru/developers/measured-quantities.md b/docs/ru/developers/measured-quantities.md new file mode 100644 index 0000000..13560da --- /dev/null +++ b/docs/ru/developers/measured-quantities.md @@ -0,0 +1,115 @@ +# Измеряемые количества + +Начиная с 1.5.0, товар можно продавать по весу, длине, объёму или количеству вместо поштучной продажи: кабель на метры, краску на литры, песок на килограммы, рекламные показы за тысячу. Количества остаются целыми числами. Измеряемое количество — это целое число **базовых единиц**: + +| Способ продажи | Базовая единица | Единицы цены | База цены | +|---|---|---|---| +| `weight` | грамм (`g`) | `kg`, `g` | 1, 10, 100 или 1000 единиц цены, не более 1 кг | +| `length` | миллиметр (`mm`) | `m`, `cm`, `mm` | 1, 10, 100 или 1000 единиц цены, не более 1 м | +| `volume` | миллилитр (`ml`) | `l`, `ml` | 1, 10, 100 или 1000 единиц цены, не более 1 л | +| `count` | штука | — | за 1, 1 000 или 1 000 000 штук | + +Цена относится к `factor` базовым единицам: базовых единиц в единице цены × база цены. `€1.20 / m` имеет factor 1000, `€2.49 / 100 g` — factor 100, `€2.50 / 1,000` показов — factor 1000. Сумма строки равна `round_half_up(price × quantity ÷ factor)` в центах и вычисляется в целых числах из неокруглённой цены (`price_regular` имеет пять знаков после запятой). Цена за единицу никогда не округляется до центов заранее. + +Поштучные товары, старые заказы и темы, которые отправляют целые количества, работают так же, как раньше. Выполните миграции sCommerce, чтобы добавить колонки товара. + +## Товары + +Форма товара содержит блок **Способ продажи** со способом продажи, единицей цены, базой цены, округлением, шагом, минимумом и допуском. Пустые значения используют значения сайта по умолчанию. Поле остатка показывает базовую единицу (**Количество, мм**): остаток измеряемого товара указывается в базовых единицах. + +| Колонка в `s_products` | Значение | +|---|---| +| `measure` | `0` штука, `1` вес, `2` длина, `3` объём, `4` количество | +| `price_unit` | код единицы, к которой относится цена (`kg`, `m`, …); `NULL` = значение сайта по умолчанию | +| `price_per` | база цены в единицах цены (`100` для цены за 100 г); `NULL` = значение сайта по умолчанию | +| `quantity_step` | шаг заказа в базовых единицах (`10` = резка с точностью до сантиметра); `NULL` = значение сайта по умолчанию | +| `quantity_min` | минимальный заказ в базовых единицах, не меньше одного шага; `NULL` = значение сайта по умолчанию | +| `quantity_tolerance` | допустимое превышение фактического количества в базисных пунктах (`500` = 5 %); `NULL` = значение сайта по умолчанию | +| `quantity_rounding` | округление до шага на витрине: `up`, `nearest` или `down`; `NULL` = настройка сайта для единицы цены | + +Изменение способа продажи товара, у которого есть остаток или который есть в заказе, отклоняется, потому что оно изменило бы значение сохранённых целых чисел. Измеряемые товары не могут иметь залога за возвратную тару. Физические характеристики измеряемого товара описывают **одну единицу цены** (граммы на метр кабеля). Товары `count` обычно являются услугами без доставки и остатка; если они ведут остаток, их максимальное количество составляет 2 000 000 000. + +Импорт и экспорт CSV содержат колонки `measure` (`piece`, `weight`, `length`, `volume`, `count`), `price_unit`, `price_per`, `quantity_step`, `quantity_min`, `quantity_tolerance` и `quantity_rounding`. Отсутствующие колонки сохраняют значения товара; недопустимые значения приводят к ошибке строки. + +## Настройки + +Вкладка настроек товаров содержит раздел **Измеряемые количества**: для каждого способа продажи единица цены, база цены, шаг, минимум, максимум (в базовых единицах), допуск и число знаков после запятой при вводе по умолчанию (`product.measures`), а также **Округление до шага по единицам** (`product.quantity_rounding`). Значения по умолчанию: `up` для длины и объёма (слишком короткий отрезок бесполезен), `nearest` для веса; единица без настройки округляется вверх. Недопустимые значения не сохраняются, и остаются предыдущие. Изменение значений по умолчанию никогда не меняет сохранённые строки заказов. + +## Витрина + +`sCart::addProduct()` сохраняет свою сигнатуру. Для измеряемых товаров поле `amount` в единице цены (`2.53` метра, всегда с разделителем `.`) имеет приоритет над `quantity` (базовые единицы). Количество увеличивается до минимума, округляется до шага по правилу округления товара, ограничивается максимумом и доступным остатком, округлённым **вниз** до шага. Если количество меняется, ответ содержит `quantityAdjusted: true`, и сообщение говорит об этом. Количество по умолчанию — минимальное. + +```blade +{{-- Amount input in the price unit; piece products get the usual quantity input. --}} +
+ {!! sCommerce::quantityInput($product) !!} + +
+{{ sCommerce::unitPrice($product) }} {{-- €2.49 / 100 g --}} + +{{-- In the cart: --}} +{!! sCommerce::quantityInput($product, ['data-sc-quantity' => $product->id, 'value' => $amount]) !!} +``` + +`quantityInput()` выводит `` с `min`, `step` и `max` в единице цены и `data-sc-measure`, `data-sc-factor` (базовых единиц в единице цены), `data-sc-step`, `data-sc-min` (базовые единицы), `data-sc-decimals` и `data-sc-unit`. Встроенный скрипт отправляет `amount` таких полей; быстрые заказы (`data-sc-fast-buy`) отправляют его вместе с остальными полями. + +Подписи соответствуют языку: единицы берутся из языкового файла (`kg` по-украински — `кг`), как и разделители дробной части и тысяч (`2,53 м`). Используйте `MeasuredQuantity::label($quantity, $rules, $locale)` и `MeasuredQuantity::unitPriceLabel($formattedPrice, $rules, $locale)`. + +## Строки заказа + +Измеряемые строки содержат эти ключи рядом с обычными; у поштучных строк нет ни одного из них: + +| Ключ | Пример | Примечания | +|---|---|---| +| `quantity` | `2530` | целое число, базовые единицы | +| `measure` | `"length"` | `weight`, `length`, `volume` или `count` | +| `base_unit` | `"mm"` | `g`, `mm`, `ml` или `unit` | +| `price_unit` | `"m"` | единица, к которой относится цена | +| `price_per` | `1` | база цены (`100` для `€2.49 / 100 g`) | +| `factor` | `1000` | количество базовых единиц, к которому относится цена, степень десяти | +| `quantity_rounding`, `quantity_step`, `quantity_min`, `quantity_tolerance` | `"up"`, `10`, `1000`, `500` | правила на момент оформления | +| `quantity_label`, `unit_price_label` | `"2.53 m"`, `"€1.20 / m"` | только для отображения, на языке заказа | +| `line_weight` | `475` | граммы для всей строки, если известно | +| `actual_quantity`, `actual_quantity_at`, `actual_quantity_by`, `actual_quantity_source`, `actual_quantity_label` | `2553` | только когда задано фактическое количество | + +Правила для потребителей: + +- Количество к оплате — `actual_quantity ?? quantity`: `MeasuredQuantity::effective($line)`. +- Правила сохранённой строки берутся из строки, никогда из текущего товара: `MeasuredQuantity::fromLine($line)`. +- Никогда не передавайте подписи как данные. Для учёта используйте `MeasuredQuantity::forAccounting($quantity, $price, $rules)`: вес, длина и объём выражаются в наибольшей единице (`0.330 kg × 24.90`, `2.530 m × 1.20`; UN/ECE Rec 20 `KGM`, `MTR`, `LTR`), а `count` — в единицах базы (`1.234567 × 1,000,000 tokens @ 0.50`). Оба варианта точны; сумма строки в учёте равна сумме строки в sCommerce. Учётная система должна принимать 3 знака после запятой в количестве и до 6 для `count`. +- Платёжные провайдеры, которым нужны целые количества, отправляют измеряемую строку с количеством 1, точной суммой строки и названием вроде `2.53 m × €1.20`. +- Интеграциям доставки следует отдавать предпочтение `line_weight` вместо `weight × quantity`. + +Форма заказа в менеджере показывает измеряемые количества в единице цены и на языке менеджера. Ключи правил и фактического количества существующих строк при сохранении берутся из сохранённой строки; строки, добавленные в менеджере, берут их из товара. Количества проверяются строго (минимум, шаг, максимум) и никогда не округляются. Повторные заказы применяют текущие правила товара и отклоняют товар, способ продажи которого изменился. + +## Фактические количества + +Отрезанные или взвешенные товары часто отличаются от заказанного количества: заказано 2530 мм, отрезано 2553 мм. `Seiger\sCommerce\Services\OrderActualQuantity::set()` записывает фактическое количество измеряемой строки: + +```php +use Seiger\sCommerce\Services\OrderActualQuantity; + +$order = (new OrderActualQuantity())->set($order, '12:0', 2553, 'erp', 0, 'cut-REF-2026-0042'); +``` + +- Аргументы: заказ, `line_key`, фактическое количество в базовых единицах, источник (`manager`, `erp` или `api`), id исполнителя, ключ идемпотентности и `overTolerance`. +- Тот же ключ с тем же значением возвращает заказ без изменений; с другим значением — это конфликт (409). Последние 50 ключей хранятся в `meta.actual_quantity_ops`. +- Проверки шага нет: фактическое количество — это измерение. Оно должно быть не меньше 1 и не больше максимума строки. +- Оно может превышать заказанное количество на `quantity_tolerance` строки. Для большего превышения нужно `overTolerance = true`, то есть согласие покупателя; в менеджере для этого нужно разрешение `scommerce_order_actual_over_tolerance`. +- Отменённые и удалённые заказы, поштучные строки и строки, которые уже есть в отправлении со статусом shipped или delivered, отклоняются (исправление после отправки — это возврат товара или средств). +- Сумма строки, розничный акциз, вес строки, стоимость заказа и история обновляются в одной транзакции с одним `sCommerce.OrderUpdated` в области событий источника. Локально списанный остаток меняется соответственно. +- Отказы выбрасывают `MeasuredQuantityException` с `messageKey` и HTTP `status`; нехватка остатка выбрасывает `StockShortageException`. + +В менеджере у измеряемых строк есть поле **Фактически** на экране заказа. + +## Остатки и отправления + +Остатки, события остатков, `StockWriter`, `StockProviderInterface` и отправления сохраняют свои сигнатуры; для измеряемых товаров их целые числа — это базовые единицы. Списание остатка и покрытие отправлениями используют количество к оплате, поэтому более длинный отрезок требует большего отправления и списывает больше остатка. `StockCommitter::adjust($order)` применяет к остатку изменённые, добавленные и удалённые строки локально списанного заказа; он выполняется после редактирования в менеджере и после изменения фактических количеств. Заказы, списанные провайдером, остаются за провайдером, который получает `sCommerce.OrderUpdated`. + +## Платежи + +`sOrder::authorizationCeiling()` возвращает стоимость плюс, для каждой измеряемой строки с допуском, сумму строки, умноженную на допуск. Методы, которые авторизуют и списывают позже, могут резервировать до этой суммы; существующие методы не меняются. Когда фактическое количество меняет стоимость, спишите новую сумму, получите разницу по ссылке на финальный платёж или верните переплату, как и при любом изменении стоимости. + +## Определение поддержки + +`sCommerce::supportsIntegration('measured-quantities')` и `sCommerce::supportsIntegration('actual-quantities')` сообщают о поддержке. `INTEGRATION_EVENTS_VERSION` остаётся `1`. diff --git a/docs/ru/developers/payments/index.md b/docs/ru/developers/payments/index.md index 7165e39..1d64aee 100644 --- a/docs/ru/developers/payments/index.md +++ b/docs/ru/developers/payments/index.md @@ -1021,6 +1021,10 @@ class MyPspPayment extends BasePaymentMethod implements RefundablePaymentMethodI Списание, о котором провайдер сначала сообщил вебхуком, записывается так же: `PaymentFlow::record()` со статусом `captured`, `providerRef` авторизации и меньшей суммой превращает строку `authorized` в частичное списание. Пришедший позже повтор исходной авторизации игнорируется. +### Измеряемые товары: предел авторизации и фактические количества + +Товары, продаваемые по длине или весу, могут быть отрезаны или взвешены сверх заказанного количества в пределах допуска строки (см. [Измеряемые количества](../measured-quantities.md#фактические-количества)). `sOrder::authorizationCeiling()` возвращает стоимость заказа плюс, для каждой измеряемой строки с допуском, сумму строки, умноженную на допуск, с округлением до центов по правилу half up. Метод, который авторизует и списывает позже, может авторизовать сумму до этого предела; это опционально, и существующие методы сохраняют свои суммы. Когда фактическое количество меняет стоимость, спишите новую сумму, получите разницу финальным платежом графика платежей или верните переплату. + ### Запись офлайн-платежей Наличные и банковские переводы не проходят через провайдера, поэтому их записывает менеджер. Для заказа, оплаченного офлайн-способом (реализующим `OfflineSettlementPaymentMethodInterface`, например **Наличные** и **Оплата на счет**), панель показывает: diff --git a/docs/uk/configuration.md b/docs/uk/configuration.md index d152818..3877716 100644 --- a/docs/uk/configuration.md +++ b/docs/uk/configuration.md @@ -13,7 +13,7 @@ sCommerce не потребує налаштування для базового - Кожне замовлення списує залишок один раз. Змінюються всі його рядки або жоден: якщо залишок товару менший, ніж потрібно замовленню, нічого не змінюється, і замовлення обробляється повторно пізніше. Залишок ніколи не стає від’ємним. - Рядки, які не враховуються в залишках (віртуальні товари та послуги), і замовлення довірених тестових користувачів ніколи не змінюють залишки. -- Повернене замовлення отримує назад рівно ту кількість, яку списало, навіть якщо його рядки потім редагувалися. Замовлення, яке виходить зі статусу скасування, знову списує залишок. +- Коли менеджер редагує товари замовлення, яке списало залишок, залишок змінюється відповідно до редагування (змінені, додані та видалені рядки). Повернене замовлення отримує назад рівно ту кількість, яка за ним утримується на цей момент. Замовлення, яке виходить зі статусу скасування, знову списує залишок. - `s_orders.inventory_committed_at` позначає замовлення, залишок яких списано; `meta.inventory` зберігає списані кількості. - Списання виконується після збереження замовлення, незалежно від слухачів подій. Прихований воркер sTask **Звірка залишків** кожні 15 хвилин повторює невдалі списання та повернення. - Якщо джерелом залишків є ERP і вона імпортує кількості, з яких уже вираховано відкриті замовлення, залиште `none`; інакше відкриті замовлення буде віднято двічі. Прив’язаний провайдер залишків також може списувати й повертати залишки в самій ERP; див. [Події залишків](developers/events.md#події-залишків). diff --git a/docs/uk/developers/deliveries.md b/docs/uk/developers/deliveries.md index ace1aa1..d21192d 100644 --- a/docs/uk/developers/deliveries.md +++ b/docs/uk/developers/deliveries.md @@ -343,6 +343,7 @@ $shipments->setStatus($shipment, 'delivered', new DateTimeImmutable('2026-10-21 - Якщо відправлення досягає `shipped`, `in_transit` або `delivered` без дати, `shipped_at` встановлюється на поточний час; `delivered` також встановлює `delivered_at`. - **Позиції** посилаються на рядки замовлення через `line` (індекс у `s_orders.products`) та/або `line_key`. `product_id` і `sku` необов’язкові й перевіряються на відповідність рядку. Кількості — додатні цілі числа; той самий рядок, вказаний двічі, підсумовується. Рядки з `requires_shipping = false` (послуги) не можна відправити. `items = null` відправляє все замовлення і не може поєднуватися з іншими активними відправленнями замовлення. - Відправлені кількості активних відправлень (усіх, крім `failed`, `returned` і `canceled`) не можуть перевищувати замовлені кількості. +- Рядки вимірюваних товарів відправляються в базових одиницях (грамах, міліметрах, мілілітрах). Якщо задано фактичну кількість, для покриття вона замінює замовлену. Використовуйте `line_weight` рядка (грами для всього рядка) замість `weight × quantity`; див. [Вимірювані кількості](measured-quantities.md#рядки-замовлення). - Скасування надісланої посилки є виправленням: її кількості звільняються, `metadata.voided_after` зберігає попередній статус, записується попередження, а статус замовлення назад не повертається. - Помилки: `InvalidArgumentException` для некоректних даних, `Seiger\sCommerce\Shipping\ShipmentConflictException`, коли посилання належить іншому замовленню або кількості перевищують замовлені чи перетинаються з іншими відправленнями замовлення. - Записи блокують рядок замовлення. Одночасна вставка того самого посилання виявляється й застосовується до наявного відправлення. diff --git a/docs/uk/developers/events.md b/docs/uk/developers/events.md index 52021c4..5080bf3 100644 --- a/docs/uk/developers/events.md +++ b/docs/uk/developers/events.md @@ -655,6 +655,12 @@ Event::listen('sCommerce.ResolveProductStock', function (array $payload) { Payload містить `event`, `productId`, `modificationId` (завжди `0`), `sku`, `oldQuantity`, `newQuantity`, `oldAvailability`, `newAvailability`, `stockSource` (хто записав: `manager`, `import`, `order`, джерело, передане в `StockWriter`, або джерело області подій для інших збережень моделі) і ключі контексту `source`, `actorId`, `correlationId`, `causationId`, `integrationActor`, `eventId` і `occurredAt`. Списання та повернення залишків за замовленнями виконуються в області подій із джерелом `inventory`. +Для товарів, що продаються на вагу, за довжиною, об’ємом або кількістю, усі кількості залишку (залишок, події, списання та `available()` провайдера) вказуються в базових одиницях: грамах, міліметрах, мілілітрах або штуках. Див. [Вимірювані кількості](measured-quantities.md#залишки-та-відправлення). + +### Залишок відстежує редагування замовлень + +Починаючи з 1.5.0, коли менеджер змінює товари замовлення, залишок якого sCommerce списав локально, залишок змінюється в тому самому запиті: `StockCommitter::adjust($order)` списує або повертає різницю для змінених, доданих і видалених рядків — як для поштучних, так і для вимірюваних товарів. Фактичні кількості вимірюваних рядків коригують залишок так само. Якщо для збільшення бракує залишку товару, редагування не зберігається. Замовлення, списані провайдером залишків, локально не коригуються; провайдер отримує `sCommerce.OrderUpdated`. Подальше повернення відновлює рівно скориговані кількості. До 1.5.0 редагування в менеджері не змінювало списаний залишок. + `sCommerce::supportsIntegration('stock-provider')` повідомляє про підтримку. ## Додаткові події менеджера та frontend diff --git a/docs/uk/developers/integration.md b/docs/uk/developers/integration.md index ae24efa..3e69da7 100644 --- a/docs/uk/developers/integration.md +++ b/docs/uk/developers/integration.md @@ -302,6 +302,7 @@ class SCommerceProductController extends BaseController | `tracks_inventory` | bool | рядок враховується в залишках | | `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#рядки-замовлення) | Податкові слухачі також можуть додавати `tax_code`, `unit_price_gross`, `tax_amount` і `prices_include_tax`. Див. [Події](events.md#рядки-замовлення-виконання-та-податки). diff --git a/docs/uk/developers/measured-quantities.md b/docs/uk/developers/measured-quantities.md new file mode 100644 index 0000000..a7e6f5d --- /dev/null +++ b/docs/uk/developers/measured-quantities.md @@ -0,0 +1,115 @@ +# Вимірювані кількості + +Починаючи з 1.5.0, товар можна продавати на вагу, за довжиною, об’ємом або кількістю замість поштучного продажу: кабель на метри, фарбу на літри, пісок на кілограми, рекламні покази за тисячу. Кількості залишаються цілими числами. Вимірювана кількість — це ціле число **базових одиниць**: + +| Спосіб продажу | Базова одиниця | Одиниці ціни | База ціни | +|---|---|---|---| +| `weight` | грам (`g`) | `kg`, `g` | 1, 10, 100 або 1000 одиниць ціни, не більше 1 кг | +| `length` | міліметр (`mm`) | `m`, `cm`, `mm` | 1, 10, 100 або 1000 одиниць ціни, не більше 1 м | +| `volume` | мілілітр (`ml`) | `l`, `ml` | 1, 10, 100 або 1000 одиниць ціни, не більше 1 л | +| `count` | штука | — | за 1, 1 000 або 1 000 000 штук | + +Ціна стосується `factor` базових одиниць: базових одиниць в одиниці ціни × база ціни. `€1.20 / m` має factor 1000, `€2.49 / 100 g` — factor 100, `€2.50 / 1,000` показів — factor 1000. Сума рядка дорівнює `round_half_up(price × quantity ÷ factor)` у центах і обчислюється в цілих числах з неокругленої ціни (`price_regular` має п’ять знаків після коми). Ціна за одиницю ніколи не округлюється до центів заздалегідь. + +Поштучні товари, старі замовлення та теми, які надсилають цілі кількості, працюють так само, як раніше. Виконайте міграції sCommerce, щоб додати колонки товару. + +## Товари + +Форма товару має блок **Спосіб продажу** зі способом продажу, одиницею ціни, базою ціни, округленням, кроком, мінімумом і допуском. Порожні значення використовують типові значення сайту. Поле залишку показує базову одиницю (**Кількість, мм**): залишок вимірюваного товару вказується в базових одиницях. + +| Колонка в `s_products` | Значення | +|---|---| +| `measure` | `0` штука, `1` вага, `2` довжина, `3` об’єм, `4` кількість | +| `price_unit` | код одиниці, до якої належить ціна (`kg`, `m`, …); `NULL` = типове значення сайту | +| `price_per` | база ціни в одиницях ціни (`100` для ціни за 100 г); `NULL` = типове значення сайту | +| `quantity_step` | крок замовлення в базових одиницях (`10` = різання з точністю до сантиметра); `NULL` = типове значення сайту | +| `quantity_min` | мінімальне замовлення в базових одиницях, щонайменше один крок; `NULL` = типове значення сайту | +| `quantity_tolerance` | допустиме перевищення фактичної кількості в базисних пунктах (`500` = 5 %); `NULL` = типове значення сайту | +| `quantity_rounding` | округлення до кроку на вітрині: `up`, `nearest` або `down`; `NULL` = налаштування сайту для одиниці ціни | + +Зміну способу продажу товару, який має залишок або є в замовленні, буде відхилено, бо вона змінила б значення збережених цілих чисел. Вимірювані товари не можуть мати заставної вартості зворотної тари. Фізичні характеристики вимірюваного товару описують **одну одиницю ціни** (грами на метр кабелю). Товари `count` зазвичай є послугами без доставки та залишку; якщо вони ведуть залишок, їхня максимальна кількість становить 2 000 000 000. + +Імпорт і експорт CSV мають колонки `measure` (`piece`, `weight`, `length`, `volume`, `count`), `price_unit`, `price_per`, `quantity_step`, `quantity_min`, `quantity_tolerance` і `quantity_rounding`. Відсутні колонки зберігають значення товару; неприпустимі значення спричиняють помилку рядка. + +## Налаштування + +Вкладка налаштувань товарів має розділ **Вимірювані кількості**: для кожного способу продажу типові одиниця ціни, база ціни, крок, мінімум, максимум (у базових одиницях), допуск і кількість знаків після коми при введенні (`product.measures`), а також **Округлення до кроку за одиницями** (`product.quantity_rounding`). Типові значення: `up` для довжини та об’єму (закороткий відрізок марний), `nearest` для ваги; одиниця без налаштування округлюється вгору. Неприпустимі значення не зберігаються, і залишаються попередні. Зміна типових значень ніколи не змінює збережені рядки замовлень. + +## Вітрина + +`sCart::addProduct()` зберігає свою сигнатуру. Для вимірюваних товарів поле `amount` в одиниці ціни (`2.53` метра, завжди з роздільником `.`) має пріоритет над `quantity` (базові одиниці). Кількість збільшується до мінімуму, округлюється до кроку за правилом округлення товару, обмежується максимумом і доступним залишком, округленим **вниз** до кроку. Якщо кількість змінюється, відповідь містить `quantityAdjusted: true`, і повідомлення про це говорить. Типова кількість — мінімальна. + +```blade +{{-- Amount input in the price unit; piece products get the usual quantity input. --}} +
+ {!! sCommerce::quantityInput($product) !!} + +
+{{ sCommerce::unitPrice($product) }} {{-- €2.49 / 100 g --}} + +{{-- In the cart: --}} +{!! sCommerce::quantityInput($product, ['data-sc-quantity' => $product->id, 'value' => $amount]) !!} +``` + +`quantityInput()` виводить `` з `min`, `step` і `max` в одиниці ціни та `data-sc-measure`, `data-sc-factor` (базових одиниць в одиниці ціни), `data-sc-step`, `data-sc-min` (базові одиниці), `data-sc-decimals` і `data-sc-unit`. Вбудований скрипт надсилає `amount` таких полів; швидкі замовлення (`data-sc-fast-buy`) надсилають його разом з іншими полями. + +Підписи відповідають мові: одиниці беруться з мовного файлу (`kg` українською — `кг`), так само як роздільники дробової частини та тисяч (`2,53 м`). Використовуйте `MeasuredQuantity::label($quantity, $rules, $locale)` і `MeasuredQuantity::unitPriceLabel($formattedPrice, $rules, $locale)`. + +## Рядки замовлення + +Вимірювані рядки містять ці ключі поряд зі звичайними; поштучні рядки не мають жодного з них: + +| Ключ | Приклад | Примітки | +|---|---|---| +| `quantity` | `2530` | ціле число, базові одиниці | +| `measure` | `"length"` | `weight`, `length`, `volume` або `count` | +| `base_unit` | `"mm"` | `g`, `mm`, `ml` або `unit` | +| `price_unit` | `"m"` | одиниця, до якої належить ціна | +| `price_per` | `1` | база ціни (`100` для `€2.49 / 100 g`) | +| `factor` | `1000` | кількість базових одиниць, до якої належить ціна, степінь десяти | +| `quantity_rounding`, `quantity_step`, `quantity_min`, `quantity_tolerance` | `"up"`, `10`, `1000`, `500` | правила на момент оформлення | +| `quantity_label`, `unit_price_label` | `"2.53 m"`, `"€1.20 / m"` | лише для відображення, мовою замовлення | +| `line_weight` | `475` | грами для всього рядка, якщо відомо | +| `actual_quantity`, `actual_quantity_at`, `actual_quantity_by`, `actual_quantity_source`, `actual_quantity_label` | `2553` | лише коли задано фактичну кількість | + +Правила для споживачів: + +- Кількість до оплати — `actual_quantity ?? quantity`: `MeasuredQuantity::effective($line)`. +- Правила збереженого рядка беруться з рядка, ніколи з поточного товару: `MeasuredQuantity::fromLine($line)`. +- Ніколи не передавайте підписи як дані. Для обліку використовуйте `MeasuredQuantity::forAccounting($quantity, $price, $rules)`: вага, довжина та об’єм виражаються в найбільшій одиниці (`0.330 kg × 24.90`, `2.530 m × 1.20`; UN/ECE Rec 20 `KGM`, `MTR`, `LTR`), а `count` — в одиницях бази (`1.234567 × 1,000,000 tokens @ 0.50`). Обидва варіанти точні; сума рядка в обліку дорівнює сумі рядка в sCommerce. Облікова система має приймати 3 знаки після коми в кількості та до 6 для `count`. +- Платіжні провайдери, яким потрібні цілі кількості, надсилають вимірюваний рядок із кількістю 1, точною сумою рядка та назвою на кшталт `2.53 m × €1.20`. +- Інтеграції доставки мають надавати перевагу `line_weight` замість `weight × quantity`. + +Форма замовлення в менеджері показує вимірювані кількості в одиниці ціни та мовою менеджера. Ключі правил і фактичної кількості наявних рядків під час збереження беруться зі збереженого рядка; рядки, додані в менеджері, беруть їх із товару. Кількості перевіряються суворо (мінімум, крок, максимум) і ніколи не округлюються. Повторні замовлення застосовують поточні правила товару та відхиляють товар, спосіб продажу якого змінився. + +## Фактичні кількості + +Відрізані або зважені товари часто відрізняються від замовленої кількості: замовлено 2530 мм, відрізано 2553 мм. `Seiger\sCommerce\Services\OrderActualQuantity::set()` записує фактичну кількість вимірюваного рядка: + +```php +use Seiger\sCommerce\Services\OrderActualQuantity; + +$order = (new OrderActualQuantity())->set($order, '12:0', 2553, 'erp', 0, 'cut-REF-2026-0042'); +``` + +- Аргументи: замовлення, `line_key`, фактична кількість у базових одиницях, джерело (`manager`, `erp` або `api`), id виконавця, ключ ідемпотентності та `overTolerance`. +- Той самий ключ із тим самим значенням повертає замовлення без змін; з іншим значенням — це конфлікт (409). Останні 50 ключів зберігаються в `meta.actual_quantity_ops`. +- Перевірки кроку немає: фактична кількість — це вимірювання. Вона має бути не меншою за 1 і не більшою за максимум рядка. +- Вона може перевищувати замовлену кількість на `quantity_tolerance` рядка. Для більшого перевищення потрібне `overTolerance = true`, тобто згода покупця; у менеджері для цього потрібен дозвіл `scommerce_order_actual_over_tolerance`. +- Скасовані та видалені замовлення, поштучні рядки й рядки, які вже є у відправленні зі статусом shipped або delivered, відхиляються (виправлення після відправлення — це повернення товару або коштів). +- Сума рядка, роздрібний акциз, вага рядка, вартість замовлення та історія оновлюються в одній транзакції з одним `sCommerce.OrderUpdated` в області подій джерела. Локально списаний залишок змінюється відповідно. +- Відмови викидають `MeasuredQuantityException` з `messageKey` і HTTP `status`; нестача залишку викидає `StockShortageException`. + +У менеджері вимірювані рядки мають поле **Фактично** на екрані замовлення. + +## Залишки та відправлення + +Залишки, події залишків, `StockWriter`, `StockProviderInterface` і відправлення зберігають свої сигнатури; для вимірюваних товарів їхні цілі числа — це базові одиниці. Списання залишку та покриття відправленнями використовують кількість до оплати, тому довший відрізок потребує більшого відправлення й списує більше залишку. `StockCommitter::adjust($order)` застосовує до залишку змінені, додані та видалені рядки локально списаного замовлення; він виконується після редагування в менеджері та після зміни фактичних кількостей. Замовлення, списані провайдером, залишаються провайдеру, який отримує `sCommerce.OrderUpdated`. + +## Платежі + +`sOrder::authorizationCeiling()` повертає вартість плюс, для кожного вимірюваного рядка з допуском, суму рядка, помножену на допуск. Методи, які авторизують і списують пізніше, можуть резервувати до цієї суми; наявні методи не змінюються. Коли фактична кількість змінює вартість, спишіть нову суму, отримайте різницю за посиланням на фінальний платіж або поверніть переплату, як і для будь-якої зміни вартості. + +## Визначення підтримки + +`sCommerce::supportsIntegration('measured-quantities')` і `sCommerce::supportsIntegration('actual-quantities')` повідомляють про підтримку. `INTEGRATION_EVENTS_VERSION` залишається `1`. diff --git a/docs/uk/developers/payments/index.md b/docs/uk/developers/payments/index.md index 568565a..d466c61 100644 --- a/docs/uk/developers/payments/index.md +++ b/docs/uk/developers/payments/index.md @@ -1021,6 +1021,10 @@ class MyPspPayment extends BasePaymentMethod implements RefundablePaymentMethodI Списання, про яке провайдер спершу повідомив вебхуком, записується так само: `PaymentFlow::record()` зі статусом `captured`, `providerRef` авторизації та меншою сумою перетворює рядок `authorized` на часткове списання. Пізніший повтор початкової авторизації ігнорується. +### Вимірювані товари: межа авторизації та фактичні кількості + +Товари, що продаються за довжиною або на вагу, можуть бути відрізані чи зважені понад замовлену кількість у межах допуску рядка (див. [Вимірювані кількості](../measured-quantities.md#фактичні-кількості)). `sOrder::authorizationCeiling()` повертає вартість замовлення плюс, для кожного вимірюваного рядка з допуском, суму рядка, помножену на допуск, з округленням до центів за правилом half up. Метод, який авторизує і списує пізніше, може авторизувати суму до цієї межі; це опційно, і наявні методи зберігають свої суми. Коли фактична кількість змінює вартість, спишіть нову суму, отримайте різницю фінальним платежем графіка платежів або поверніть переплату. + ### Запис офлайн-платежів Готівку та банківські перекази не проводить провайдер, тому їх записує менеджер. Для замовлення, оплаченого офлайн-способом (що реалізує `OfflineSettlementPaymentMethodInterface`, як-от **Готівка** і **Оплата на рахунок**), панель показує: diff --git a/lang/de/global.php b/lang/de/global.php index 6f3c6f4..7c36ef7 100644 --- a/lang/de/global.php +++ b/lang/de/global.php @@ -255,4 +255,80 @@ "stock_reconciliation_worker" => "Bestandsabgleich", "stock_reconciliation_worker_desc" => "Wiederholt fehlgeschlagene Bestandsabbuchungen und -rückbuchungen von Bestellungen, zum Beispiel wegen fehlenden Bestands.", "stock_reconciliation_worker_done" => "Aktualisierte Bestellungen: :orders.", + "measure" => "Verkaufsart", + "measure_help" => "Wie das Produkt verkauft wird. Gewicht wird in Gramm gespeichert, Länge in Millimetern, Volumen in Millilitern und Anzahl in Stück. Der Preis bezieht sich auf die Preiseinheit. Die Verkaufsart kann nicht geändert werden, solange das Produkt Bestand oder Bestellungen hat.", + "measure_piece" => "Stück", + "measure_weight" => "Gewicht", + "measure_length" => "Länge", + "measure_volume" => "Volumen", + "measure_count" => "Anzahl", + "price_unit" => "Preiseinheit", + "price_unit_help" => "Die Einheit, auf die sich der Preis bezieht, z. B. pro Kilogramm oder pro Meter. Wenn leer, gilt der Standardwert der Website.", + "price_per" => "Preis pro", + "price_per_help" => "Der Preis gilt für so viele Preiseinheiten, z. B. ein Preis pro 100 g. Höchstens ein Kilogramm, Meter oder Liter; bei Anzahl pro 1, 1.000 oder 1.000.000.", + "quantity_step" => "Mengenschritt", + "quantity_step_help" => "Kunden bestellen Vielfache dieser Anzahl an Basiseinheiten, z. B. 10 mm für einen Zuschnitt auf den Zentimeter genau. Wenn leer, gilt der Standardwert der Website.", + "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_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", + "quantity_rounding_help" => "Wie der Shop eine eingegebene Menge auf den Schritt rundet. Wenn leer, gilt die Website-Einstellung der Preiseinheit.", + "quantity_rounding_up" => "Aufrunden", + "quantity_rounding_nearest" => "Zum nächsten Schritt", + "quantity_rounding_down" => "Abrunden", + "quantity_input_decimals" => "Nachkommastellen bei Eingabe", + "site_default" => "Standard der Website", + "inventory_in_unit" => "Menge, :unit", + "measures_settings" => "Gemessene Mengen", + "measures_settings_help" => "Standardwerte für Produkte, die nach Gewicht, Länge, Volumen oder Anzahl verkauft werden. Schritt, Minimum und Maximum sind in Basiseinheiten (g, mm, ml, Stück). Produkte können jeden Wert überschreiben.", + "quantity_rounding_settings" => "Rundung auf den Schritt je Einheit", + "quantity_rounding_settings_help" => "Wie der Shop eine eingegebene Menge je Preiseinheit auf den Schritt rundet. Bestandsgrenzen werden immer abgerundet.", + "unit_g" => "g", + "unit_kg" => "kg", + "unit_mm" => "mm", + "unit_cm" => "cm", + "unit_m" => "m", + "unit_ml" => "ml", + "unit_l" => "l", + "number_decimal_separator" => ",", + "number_thousands_separator" => ".", + "quantity_label" => ":quantity :unit", + "unit_price_label" => ":price / :basis :unit", + "count_basis_1000" => "1.000", + "count_basis_1000000" => "1.000.000", + "measured_quantity_invalid" => "Geben Sie eine gültige Menge ein.", + "measured_quantity_below_min" => "Die Mindestmenge beträgt :min.", + "measured_quantity_step" => "Die Menge muss ein Vielfaches von :step sein.", + "measured_quantity_above_max" => "Die Höchstmenge beträgt :max.", + "measured_quantity_precision" => "Geben Sie höchstens :decimals Nachkommastellen ein.", + "measured_settings_measure" => "Unbekannte Verkaufsart.", + "measured_settings_price_unit" => "Die Preiseinheit passt nicht zur Verkaufsart.", + "measured_settings_price_per" => "Die Preisbasis ist nicht zulässig: höchstens ein Kilogramm, Meter oder Liter oder bei Anzahl pro 1, 1.000 oder 1.000.000.", + "measured_settings_step" => "Der Mengenschritt muss eine ganze Zahl von mindestens 1 sein.", + "measured_settings_min" => "Die Mindestmenge muss mindestens einen Schritt betragen.", + "measured_settings_max" => "Die Höchstmenge muss mindestens der Mindestmenge entsprechen und darf bei Produkten mit Bestand höchstens 2.000.000.000 betragen.", + "measured_settings_tolerance" => "Die Toleranz muss zwischen 0 und 10000 Basispunkten liegen.", + "measured_settings_decimals" => "Die Nachkommastellen bei Eingabe müssen zwischen 0 und 6 liegen.", + "measured_settings_rounding" => "Die Rundung muss Aufrunden, zum nächsten Schritt oder Abrunden sein.", + "measured_change_refused" => "Die Verkaufsart kann nicht geändert werden, solange das Produkt Bestand oder Bestellungen hat. Die gespeicherten Mengen würden ihre Bedeutung ändern.", + "measured_deposit_refused" => "Produkte, die nach Gewicht, Länge, Volumen oder Anzahl verkauft werden, können kein Pfand haben.", + "repeat_measure_changed" => "Ein Produkt wird jetzt mit einer anderen Verkaufsart verkauft als in der Ursprungsbestellung. Entfernen Sie es und fügen Sie es erneut hinzu.", + "order_stock_shortage" => "Die Produkte der Bestellung wurden nicht gespeichert: nicht genügend Bestand für die geänderten Mengen.", + "actual_quantity" => "Ist-Menge", + "actual_quantity_help" => "Die tatsächlich zugeschnittene oder gewogene Menge. Danach wird die Position berechnet, versendet und vom Bestand abgebucht.", + "actual_quantity_save" => "Ist-Menge speichern", + "actual_quantity_saved" => "Ist-Menge gespeichert.", + "actual_quantity_over_tolerance" => "Der Kunde hat der größeren Menge zugestimmt", + "actual_quantity_tolerance_exceeded" => "Die Ist-Menge überschreitet die zulässige Toleranz. Bestätigen Sie, dass der Kunde zugestimmt hat.", + "actual_quantity_forbidden" => "Sie dürfen die Toleranz nicht überschreiten.", + "actual_quantity_line" => "Die Bestellposition wurde nicht gefunden.", + "actual_quantity_piece" => "Ist-Mengen gelten für Produkte, die nach Gewicht, Länge, Volumen oder Anzahl verkauft werden.", + "actual_quantity_order_closed" => "Die Bestellung ist storniert oder gelöscht.", + "actual_quantity_shipped" => "Die Position wurde bereits versendet. Verwenden Sie stattdessen eine Rücksendung oder Erstattung.", + "actual_quantity_conflict" => "Dieser Vorgang wurde bereits mit einer anderen Menge ausgeführt.", + "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", ]); diff --git a/lang/en/global.php b/lang/en/global.php index ac9cd95..de45282 100644 --- a/lang/en/global.php +++ b/lang/en/global.php @@ -750,4 +750,80 @@ "stock_reconciliation_worker" => "Stock reconciliation", "stock_reconciliation_worker_desc" => "Retries stock reductions and returns of orders that failed, for example because of missing stock.", "stock_reconciliation_worker_done" => "Updated orders: :orders.", + "measure" => "Sold by", + "measure_help" => "How the product is sold. Weight is stored in grams, length in millimetres, volume in millilitres and count in items. The price refers to the price unit. It cannot change while the product has stock or orders.", + "measure_piece" => "Piece", + "measure_weight" => "Weight", + "measure_length" => "Length", + "measure_volume" => "Volume", + "measure_count" => "Count", + "price_unit" => "Price unit", + "price_unit_help" => "The unit the price refers to, e.g. per kilogram or per metre. Empty uses the site default.", + "price_per" => "Price per", + "price_per_help" => "The price refers to this many price units, e.g. a price per 100 g. At most one kilogram, metre or litre; for count, per 1, 1,000 or 1,000,000.", + "quantity_step" => "Quantity step", + "quantity_step_help" => "Customers order multiples of this many base units, e.g. 10 mm to cut to the centimetre. Empty uses the site default.", + "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_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", + "quantity_rounding_help" => "How the storefront rounds an entered quantity to the step. Empty uses the site setting of the price unit.", + "quantity_rounding_up" => "Up", + "quantity_rounding_nearest" => "Nearest", + "quantity_rounding_down" => "Down", + "quantity_input_decimals" => "Input decimals", + "site_default" => "Site default", + "inventory_in_unit" => "Quantity, :unit", + "measures_settings" => "Measured quantities", + "measures_settings_help" => "Defaults for products sold by weight, length, volume or count. Step, minimum and maximum are in base units (g, mm, ml, items). Products can override each value.", + "quantity_rounding_settings" => "Rounding to the step per unit", + "quantity_rounding_settings_help" => "How the storefront rounds an entered quantity to the step, per price unit. Stock limits always round down.", + "unit_g" => "g", + "unit_kg" => "kg", + "unit_mm" => "mm", + "unit_cm" => "cm", + "unit_m" => "m", + "unit_ml" => "ml", + "unit_l" => "l", + "number_decimal_separator" => ".", + "number_thousands_separator" => ",", + "quantity_label" => ":quantity :unit", + "unit_price_label" => ":price / :basis :unit", + "count_basis_1000" => "1,000", + "count_basis_1000000" => "1,000,000", + "measured_quantity_invalid" => "Enter a valid quantity.", + "measured_quantity_below_min" => "The minimum quantity is :min.", + "measured_quantity_step" => "The quantity must be a multiple of :step.", + "measured_quantity_above_max" => "The maximum quantity is :max.", + "measured_quantity_precision" => "Enter at most :decimals decimals.", + "measured_settings_measure" => "Unknown sales measure.", + "measured_settings_price_unit" => "The price unit does not belong to the measure.", + "measured_settings_price_per" => "The price basis is not allowed: at most one kilogram, metre or litre, or per 1, 1,000 or 1,000,000 for count.", + "measured_settings_step" => "The quantity step must be a whole number of at least 1.", + "measured_settings_min" => "The minimum quantity must be at least one step.", + "measured_settings_max" => "The maximum quantity must be at least the minimum and at most 2,000,000,000 for products with stock.", + "measured_settings_tolerance" => "The tolerance must be between 0 and 10000 basis points.", + "measured_settings_decimals" => "Input decimals must be between 0 and 6.", + "measured_settings_rounding" => "Rounding must be up, nearest or down.", + "measured_change_refused" => "The sales measure cannot change while the product has stock or orders. Its stored quantities would change their meaning.", + "measured_deposit_refused" => "Products sold by weight, length, volume or count cannot have a container deposit.", + "repeat_measure_changed" => "A product is now sold by a different measure than in the source order. Remove it and add it again.", + "order_stock_shortage" => "The products of the order were not saved: not enough stock for the changed quantities.", + "actual_quantity" => "Actual", + "actual_quantity_help" => "The quantity actually cut or weighed. The line is billed, shipped and taken from stock with it.", + "actual_quantity_save" => "Save actual", + "actual_quantity_saved" => "Actual quantity saved.", + "actual_quantity_over_tolerance" => "The customer agreed to the larger quantity", + "actual_quantity_tolerance_exceeded" => "The actual quantity exceeds the allowed tolerance. Confirm that the customer agreed.", + "actual_quantity_forbidden" => "You are not allowed to exceed the tolerance.", + "actual_quantity_line" => "The order line was not found.", + "actual_quantity_piece" => "Actual quantities are for products sold by weight, length, volume or count.", + "actual_quantity_order_closed" => "The order is cancelled or deleted.", + "actual_quantity_shipped" => "The line has already been shipped. Use a return or refund instead.", + "actual_quantity_conflict" => "This operation was already applied with a different quantity.", + "actual_quantity_unavailable" => "Actual quantities need the latest sCommerce migrations.", + "actual_quantity_stock" => "Not enough stock for the actual quantity.", + "permission_order_actual_over_tolerance" => "Exceed the actual-quantity tolerance", ]; diff --git a/lang/fr/global.php b/lang/fr/global.php index a0ef48f..c644487 100644 --- a/lang/fr/global.php +++ b/lang/fr/global.php @@ -255,4 +255,80 @@ "stock_reconciliation_worker" => "Rapprochement des stocks", "stock_reconciliation_worker_desc" => "Relance les déductions et remises en stock des commandes qui ont échoué, par exemple en raison d’un stock insuffisant.", "stock_reconciliation_worker_done" => "Commandes mises à jour : :orders.", + "measure" => "Mode de vente", + "measure_help" => "Comment le produit est vendu. Le poids est stocké en grammes, la longueur en millimètres, le volume en millilitres et le nombre en pièces. Le prix se rapporte à l’unité de prix. Le mode de vente ne peut pas changer tant que le produit a du stock ou des commandes.", + "measure_piece" => "Pièce", + "measure_weight" => "Poids", + "measure_length" => "Longueur", + "measure_volume" => "Volume", + "measure_count" => "Nombre", + "price_unit" => "Unité de prix", + "price_unit_help" => "L’unité à laquelle le prix se rapporte, par ex. au kilogramme ou au mètre. Si vide, la valeur par défaut du site est utilisée.", + "price_per" => "Prix pour", + "price_per_help" => "Le prix s’applique à ce nombre d’unités de prix, par ex. un prix pour 100 g. Au plus un kilogramme, un mètre ou un litre ; pour le nombre, par 1, 1 000 ou 1 000 000.", + "quantity_step" => "Pas de quantité", + "quantity_step_help" => "Les clients commandent des multiples de ce nombre d’unités de base, par ex. 10 mm pour une découpe au centimètre près. Si vide, la valeur par défaut du site est utilisée.", + "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_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", + "quantity_rounding_help" => "Comment la boutique arrondit une quantité saisie au pas. Si vide, le réglage du site pour l’unité de prix est utilisé.", + "quantity_rounding_up" => "Vers le haut", + "quantity_rounding_nearest" => "Au plus proche", + "quantity_rounding_down" => "Vers le bas", + "quantity_input_decimals" => "Décimales à la saisie", + "site_default" => "Valeur par défaut du site", + "inventory_in_unit" => "Quantité, :unit", + "measures_settings" => "Quantités mesurées", + "measures_settings_help" => "Valeurs par défaut des produits vendus au poids, à la longueur, au volume ou au nombre. Le pas, le minimum et le maximum sont en unités de base (g, mm, ml, pièces). Les produits peuvent remplacer chaque valeur.", + "quantity_rounding_settings" => "Arrondi au pas par unité", + "quantity_rounding_settings_help" => "Comment la boutique arrondit une quantité saisie au pas, par unité de prix. Les limites de stock sont toujours arrondies vers le bas.", + "unit_g" => "g", + "unit_kg" => "kg", + "unit_mm" => "mm", + "unit_cm" => "cm", + "unit_m" => "m", + "unit_ml" => "ml", + "unit_l" => "l", + "number_decimal_separator" => ",", + "number_thousands_separator" => " ", + "quantity_label" => ":quantity :unit", + "unit_price_label" => ":price / :basis :unit", + "count_basis_1000" => "1 000", + "count_basis_1000000" => "1 000 000", + "measured_quantity_invalid" => "Saisissez une quantité valide.", + "measured_quantity_below_min" => "La quantité minimale est de :min.", + "measured_quantity_step" => "La quantité doit être un multiple de :step.", + "measured_quantity_above_max" => "La quantité maximale est de :max.", + "measured_quantity_precision" => "Saisissez au plus :decimals décimales.", + "measured_settings_measure" => "Mode de vente inconnu.", + "measured_settings_price_unit" => "L’unité de prix ne correspond pas au mode de vente.", + "measured_settings_price_per" => "La base de prix n’est pas autorisée : au plus un kilogramme, un mètre ou un litre, ou par 1, 1 000 ou 1 000 000 pour le nombre.", + "measured_settings_step" => "Le pas de quantité doit être un nombre entier d’au moins 1.", + "measured_settings_min" => "La quantité minimale doit être d’au moins un pas.", + "measured_settings_max" => "La quantité maximale doit être au moins égale au minimum et d’au plus 2 000 000 000 pour les produits avec stock.", + "measured_settings_tolerance" => "La tolérance doit être comprise entre 0 et 10000 points de base.", + "measured_settings_decimals" => "Les décimales à la saisie doivent être comprises entre 0 et 6.", + "measured_settings_rounding" => "L’arrondi doit être vers le haut, au plus proche ou vers le bas.", + "measured_change_refused" => "Le mode de vente ne peut pas changer tant que le produit a du stock ou des commandes. Ses quantités enregistrées changeraient de signification.", + "measured_deposit_refused" => "Les produits vendus au poids, à la longueur, au volume ou au nombre ne peuvent pas avoir de consigne.", + "repeat_measure_changed" => "Un produit est désormais vendu selon un autre mode de vente que dans la commande d’origine. Supprimez-le et ajoutez-le de nouveau.", + "order_stock_shortage" => "Les produits de la commande n’ont pas été enregistrés : stock insuffisant pour les quantités modifiées.", + "actual_quantity" => "Réelle", + "actual_quantity_help" => "La quantité réellement coupée ou pesée. La ligne est facturée, expédiée et déduite du stock sur cette base.", + "actual_quantity_save" => "Enregistrer la quantité réelle", + "actual_quantity_saved" => "Quantité réelle enregistrée.", + "actual_quantity_over_tolerance" => "Le client a accepté la quantité supérieure", + "actual_quantity_tolerance_exceeded" => "La quantité réelle dépasse la tolérance autorisée. Confirmez que le client a donné son accord.", + "actual_quantity_forbidden" => "Vous n’êtes pas autorisé à dépasser la tolérance.", + "actual_quantity_line" => "La ligne de commande est introuvable.", + "actual_quantity_piece" => "Les quantités réelles concernent les produits vendus au poids, à la longueur, au volume ou au nombre.", + "actual_quantity_order_closed" => "La commande est annulée ou supprimée.", + "actual_quantity_shipped" => "La ligne a déjà été expédiée. Utilisez plutôt un retour ou un remboursement.", + "actual_quantity_conflict" => "Cette opération a déjà été appliquée avec une autre quantité.", + "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", ]); diff --git a/lang/pl/global.php b/lang/pl/global.php index d9c622f..e8e3d81 100644 --- a/lang/pl/global.php +++ b/lang/pl/global.php @@ -255,4 +255,80 @@ "stock_reconciliation_worker" => "Uzgadnianie stanów magazynowych", "stock_reconciliation_worker_desc" => "Ponawia nieudane zmniejszenia i zwroty stanu dla zamówień, na przykład z powodu braku towaru.", "stock_reconciliation_worker_done" => "Zaktualizowane zamówienia: :orders.", + "measure" => "Sposób sprzedaży", + "measure_help" => "Jak produkt jest sprzedawany. Waga jest zapisywana w gramach, długość w milimetrach, objętość w mililitrach, a liczba w sztukach. Cena dotyczy jednostki ceny. Sposobu sprzedaży nie można zmienić, dopóki produkt ma stan magazynowy lub zamówienia.", + "measure_piece" => "Sztuka", + "measure_weight" => "Waga", + "measure_length" => "Długość", + "measure_volume" => "Objętość", + "measure_count" => "Liczba sztuk", + "price_unit" => "Jednostka ceny", + "price_unit_help" => "Jednostka, której dotyczy cena, np. za kilogram lub za metr. Puste pole oznacza wartość domyślną witryny.", + "price_per" => "Cena za", + "price_per_help" => "Cena dotyczy takiej liczby jednostek ceny, np. cena za 100 g. Maksymalnie jeden kilogram, metr lub litr; dla liczby sztuk – za 1, 1000 lub 1 000 000.", + "quantity_step" => "Krok ilości", + "quantity_step_help" => "Klienci zamawiają wielokrotności tej liczby jednostek bazowych, np. 10 mm, aby ciąć z dokładnością do centymetra. Puste pole oznacza wartość domyślną witryny.", + "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_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", + "quantity_rounding_help" => "Jak sklep zaokrągla wprowadzoną ilość do kroku. Puste pole oznacza ustawienie witryny dla jednostki ceny.", + "quantity_rounding_up" => "W górę", + "quantity_rounding_nearest" => "Do najbliższego", + "quantity_rounding_down" => "W dół", + "quantity_input_decimals" => "Miejsca dziesiętne przy wprowadzaniu", + "site_default" => "Domyślne dla witryny", + "inventory_in_unit" => "Ilość, :unit", + "measures_settings" => "Ilości mierzone", + "measures_settings_help" => "Wartości domyślne dla produktów sprzedawanych na wagę, długość, objętość lub liczbę sztuk. Krok, minimum i maksimum podaje się w jednostkach bazowych (g, mm, ml, szt.). Produkty mogą nadpisać każdą wartość.", + "quantity_rounding_settings" => "Zaokrąglanie do kroku według jednostki", + "quantity_rounding_settings_help" => "Jak sklep zaokrągla wprowadzoną ilość do kroku, dla każdej jednostki ceny. Limity stanu magazynowego są zawsze zaokrąglane w dół.", + "unit_g" => "g", + "unit_kg" => "kg", + "unit_mm" => "mm", + "unit_cm" => "cm", + "unit_m" => "m", + "unit_ml" => "ml", + "unit_l" => "l", + "number_decimal_separator" => ",", + "number_thousands_separator" => " ", + "quantity_label" => ":quantity :unit", + "unit_price_label" => ":price / :basis :unit", + "count_basis_1000" => "1000", + "count_basis_1000000" => "1 000 000", + "measured_quantity_invalid" => "Wprowadź prawidłową ilość.", + "measured_quantity_below_min" => "Minimalna ilość to :min.", + "measured_quantity_step" => "Ilość musi być wielokrotnością :step.", + "measured_quantity_above_max" => "Maksymalna ilość to :max.", + "measured_quantity_precision" => "Wprowadź co najwyżej :decimals miejsc po przecinku.", + "measured_settings_measure" => "Nieznany sposób sprzedaży.", + "measured_settings_price_unit" => "Jednostka ceny nie pasuje do sposobu sprzedaży.", + "measured_settings_price_per" => "Niedozwolona podstawa ceny: maksymalnie jeden kilogram, metr lub litr albo za 1, 1000 lub 1 000 000 dla liczby sztuk.", + "measured_settings_step" => "Krok ilości musi być liczbą całkowitą nie mniejszą niż 1.", + "measured_settings_min" => "Minimalna ilość musi wynosić co najmniej jeden krok.", + "measured_settings_max" => "Maksymalna ilość musi być co najmniej równa minimalnej i wynosić co najwyżej 2 000 000 000 dla produktów ze stanem magazynowym.", + "measured_settings_tolerance" => "Tolerancja musi wynosić od 0 do 10000 punktów bazowych.", + "measured_settings_decimals" => "Liczba miejsc dziesiętnych przy wprowadzaniu musi wynosić od 0 do 6.", + "measured_settings_rounding" => "Zaokrąglanie musi być w górę, do najbliższego lub w dół.", + "measured_change_refused" => "Sposobu sprzedaży nie można zmienić, dopóki produkt ma stan magazynowy lub zamówienia. Zapisane ilości zmieniłyby znaczenie.", + "measured_deposit_refused" => "Produkty sprzedawane na wagę, długość, objętość lub liczbę sztuk nie mogą mieć kaucji za opakowanie.", + "repeat_measure_changed" => "Produkt jest teraz sprzedawany w inny sposób niż w zamówieniu źródłowym. Usuń go i dodaj ponownie.", + "order_stock_shortage" => "Produkty zamówienia nie zostały zapisane: za mały stan magazynowy dla zmienionych ilości.", + "actual_quantity" => "Rzeczywista", + "actual_quantity_help" => "Ilość faktycznie docięta lub zważona. Według niej pozycja jest rozliczana, wysyłana i pobierana ze stanu magazynowego.", + "actual_quantity_save" => "Zapisz rzeczywistą ilość", + "actual_quantity_saved" => "Zapisano rzeczywistą ilość.", + "actual_quantity_over_tolerance" => "Klient zgodził się na większą ilość", + "actual_quantity_tolerance_exceeded" => "Rzeczywista ilość przekracza dopuszczalną tolerancję. Potwierdź, że klient wyraził zgodę.", + "actual_quantity_forbidden" => "Nie masz uprawnień do przekraczania tolerancji.", + "actual_quantity_line" => "Nie znaleziono pozycji zamówienia.", + "actual_quantity_piece" => "Ilości rzeczywiste dotyczą produktów sprzedawanych na wagę, długość, objętość lub liczbę sztuk.", + "actual_quantity_order_closed" => "Zamówienie jest anulowane lub usunięte.", + "actual_quantity_shipped" => "Pozycja została już wysłana. Zamiast tego użyj zwrotu towaru lub zwrotu płatności.", + "actual_quantity_conflict" => "Ta operacja została już wykonana z inną ilością.", + "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", ]); diff --git a/lang/ru/global.php b/lang/ru/global.php index ddba70a..34578c7 100644 --- a/lang/ru/global.php +++ b/lang/ru/global.php @@ -751,4 +751,80 @@ "stock_reconciliation_worker" => "Сверка остатков", "stock_reconciliation_worker_desc" => "Повторяет неудавшиеся списания и возвраты остатка по заказам, например из-за нехватки товара.", "stock_reconciliation_worker_done" => "Обновлено заказов: :orders.", + "measure" => "Способ продажи", + "measure_help" => "Как продаётся товар. Вес хранится в граммах, длина — в миллиметрах, объём — в миллилитрах, количество — в штуках. Цена указывается за единицу цены. Способ продажи нельзя изменить, пока у товара есть остаток или заказы.", + "measure_piece" => "Штука", + "measure_weight" => "Вес", + "measure_length" => "Длина", + "measure_volume" => "Объём", + "measure_count" => "Количество", + "price_unit" => "Единица цены", + "price_unit_help" => "Единица, к которой относится цена, например за килограмм или за метр. Если не заполнено, используется значение сайта по умолчанию.", + "price_per" => "Цена за", + "price_per_help" => "Цена указывается за такое количество единиц цены, например цена за 100 г. Не более одного килограмма, метра или литра; для количества — за 1, 1 000 или 1 000 000.", + "quantity_step" => "Шаг количества", + "quantity_step_help" => "Покупатели заказывают количество, кратное этому числу базовых единиц, например 10 мм, чтобы резать с точностью до сантиметра. Если не заполнено, используется значение сайта по умолчанию.", + "quantity_min" => "Минимальное количество", + "quantity_min_help" => "Наименьший заказ в базовых единицах; не меньше одного шага. Если не заполнено, используется значение сайта по умолчанию.", + "quantity_max" => "Максимальное количество", + "quantity_tolerance" => "Допуск превышения", + "quantity_tolerance_help" => "Насколько фактическое количество (отрезанное или взвешенное после заказа) может превышать заказанное, в базисных пунктах: 500 = 5%. Если не заполнено, используется значение сайта по умолчанию.", + "quantity_rounding" => "Округление до шага", + "quantity_rounding_help" => "Как витрина округляет введённое количество до шага. Если не заполнено, используется настройка сайта для единицы цены.", + "quantity_rounding_up" => "Вверх", + "quantity_rounding_nearest" => "До ближайшего", + "quantity_rounding_down" => "Вниз", + "quantity_input_decimals" => "Знаков после запятой при вводе", + "site_default" => "По умолчанию для сайта", + "inventory_in_unit" => "Количество, :unit", + "measures_settings" => "Измеряемые количества", + "measures_settings_help" => "Значения по умолчанию для товаров, продаваемых по весу, длине, объёму или количеству. Шаг, минимум и максимум задаются в базовых единицах (г, мм, мл, шт.). Товары могут переопределять каждое значение.", + "quantity_rounding_settings" => "Округление до шага по единицам", + "quantity_rounding_settings_help" => "Как витрина округляет введённое количество до шага для каждой единицы цены. Ограничения по остатку всегда округляются вниз.", + "unit_g" => "г", + "unit_kg" => "кг", + "unit_mm" => "мм", + "unit_cm" => "см", + "unit_m" => "м", + "unit_ml" => "мл", + "unit_l" => "л", + "number_decimal_separator" => ",", + "number_thousands_separator" => " ", + "quantity_label" => ":quantity :unit", + "unit_price_label" => ":price / :basis :unit", + "count_basis_1000" => "1 000", + "count_basis_1000000" => "1 000 000", + "measured_quantity_invalid" => "Введите корректное количество.", + "measured_quantity_below_min" => "Минимальное количество — :min.", + "measured_quantity_step" => "Количество должно быть кратно :step.", + "measured_quantity_above_max" => "Максимальное количество — :max.", + "measured_quantity_precision" => "Введите не более :decimals знаков после запятой.", + "measured_settings_measure" => "Неизвестный способ продажи.", + "measured_settings_price_unit" => "Единица цены не соответствует способу продажи.", + "measured_settings_price_per" => "Недопустимая база цены: не более одного килограмма, метра или литра либо за 1, 1 000 или 1 000 000 для количества.", + "measured_settings_step" => "Шаг количества должен быть целым числом не меньше 1.", + "measured_settings_min" => "Минимальное количество должно быть не меньше одного шага.", + "measured_settings_max" => "Максимальное количество должно быть не меньше минимального и не больше 2 000 000 000 для товаров с остатком.", + "measured_settings_tolerance" => "Допуск должен быть от 0 до 10000 базисных пунктов.", + "measured_settings_decimals" => "Число знаков после запятой при вводе должно быть от 0 до 6.", + "measured_settings_rounding" => "Округление должно быть вверх, до ближайшего или вниз.", + "measured_change_refused" => "Способ продажи нельзя изменить, пока у товара есть остаток или заказы. Сохранённые количества изменили бы своё значение.", + "measured_deposit_refused" => "Товары, продаваемые по весу, длине, объёму или количеству, не могут иметь залог за тару.", + "repeat_measure_changed" => "Товар теперь продаётся иначе, чем в исходном заказе. Удалите его и добавьте снова.", + "order_stock_shortage" => "Товары заказа не сохранены: недостаточно остатка для изменённых количеств.", + "actual_quantity" => "Фактически", + "actual_quantity_help" => "Количество, фактически отрезанное или взвешенное. По нему строка оплачивается, отправляется и списывается с остатка.", + "actual_quantity_save" => "Сохранить фактическое", + "actual_quantity_saved" => "Фактическое количество сохранено.", + "actual_quantity_over_tolerance" => "Покупатель согласился на большее количество", + "actual_quantity_tolerance_exceeded" => "Фактическое количество превышает разрешённый допуск. Подтвердите, что покупатель согласился.", + "actual_quantity_forbidden" => "Вам не разрешено превышать допуск.", + "actual_quantity_line" => "Строка заказа не найдена.", + "actual_quantity_piece" => "Фактическое количество применяется к товарам, продаваемым по весу, длине, объёму или количеству.", + "actual_quantity_order_closed" => "Заказ отменён или удалён.", + "actual_quantity_shipped" => "Строка уже отправлена. Вместо этого оформите возврат товара или средств.", + "actual_quantity_conflict" => "Эта операция уже выполнена с другим количеством.", + "actual_quantity_unavailable" => "Для фактического количества нужны последние миграции sCommerce.", + "actual_quantity_stock" => "Недостаточно остатка для фактического количества.", + "permission_order_actual_over_tolerance" => "Превышение допуска фактического количества", ]; diff --git a/lang/uk/global.php b/lang/uk/global.php index a17e4b0..2f68d1d 100644 --- a/lang/uk/global.php +++ b/lang/uk/global.php @@ -752,4 +752,80 @@ "stock_reconciliation_worker" => "Звірка залишків", "stock_reconciliation_worker_desc" => "Повторює невдалі списання та повернення залишку за замовленнями, наприклад через нестачу товару.", "stock_reconciliation_worker_done" => "Оновлено замовлень: :orders.", + "measure" => "Спосіб продажу", + "measure_help" => "Як продається товар. Вага зберігається в грамах, довжина — у міліметрах, об'єм — у мілілітрах, кількість — у штуках. Ціна вказується за одиницю ціни. Спосіб продажу не можна змінити, поки товар має залишок або замовлення.", + "measure_piece" => "Штука", + "measure_weight" => "Вага", + "measure_length" => "Довжина", + "measure_volume" => "Об'єм", + "measure_count" => "Кількість", + "price_unit" => "Одиниця ціни", + "price_unit_help" => "Одиниця, до якої належить ціна, наприклад за кілограм або за метр. Якщо не заповнено, використовується типове значення сайту.", + "price_per" => "Ціна за", + "price_per_help" => "Ціна вказується за таку кількість одиниць ціни, наприклад ціна за 100 г. Не більше одного кілограма, метра чи літра; для кількості — за 1, 1 000 або 1 000 000.", + "quantity_step" => "Крок кількості", + "quantity_step_help" => "Покупці замовляють кількість, кратну цьому числу базових одиниць, наприклад 10 мм, щоб різати з точністю до сантиметра. Якщо не заповнено, використовується типове значення сайту.", + "quantity_min" => "Мінімальна кількість", + "quantity_min_help" => "Найменше замовлення в базових одиницях; щонайменше один крок. Якщо не заповнено, використовується типове значення сайту.", + "quantity_max" => "Максимальна кількість", + "quantity_tolerance" => "Допуск перевищення", + "quantity_tolerance_help" => "Наскільки фактична кількість (відрізана або зважена після замовлення) може перевищувати замовлену, у базисних пунктах: 500 = 5%. Якщо не заповнено, використовується типове значення сайту.", + "quantity_rounding" => "Округлення до кроку", + "quantity_rounding_help" => "Як вітрина округлює введену кількість до кроку. Якщо не заповнено, використовується налаштування сайту для одиниці ціни.", + "quantity_rounding_up" => "Вгору", + "quantity_rounding_nearest" => "До найближчого", + "quantity_rounding_down" => "Вниз", + "quantity_input_decimals" => "Знаків після коми при введенні", + "site_default" => "Типове для сайту", + "inventory_in_unit" => "Кількість, :unit", + "measures_settings" => "Вимірювані кількості", + "measures_settings_help" => "Типові значення для товарів, що продаються на вагу, за довжиною, об'ємом або кількістю. Крок, мінімум і максимум задаються в базових одиницях (г, мм, мл, шт.). Товари можуть перевизначати кожне значення.", + "quantity_rounding_settings" => "Округлення до кроку за одиницями", + "quantity_rounding_settings_help" => "Як вітрина округлює введену кількість до кроку для кожної одиниці ціни. Обмеження за залишком завжди округлюються вниз.", + "unit_g" => "г", + "unit_kg" => "кг", + "unit_mm" => "мм", + "unit_cm" => "см", + "unit_m" => "м", + "unit_ml" => "мл", + "unit_l" => "л", + "number_decimal_separator" => ",", + "number_thousands_separator" => " ", + "quantity_label" => ":quantity :unit", + "unit_price_label" => ":price / :basis :unit", + "count_basis_1000" => "1 000", + "count_basis_1000000" => "1 000 000", + "measured_quantity_invalid" => "Введіть коректну кількість.", + "measured_quantity_below_min" => "Мінімальна кількість — :min.", + "measured_quantity_step" => "Кількість має бути кратною :step.", + "measured_quantity_above_max" => "Максимальна кількість — :max.", + "measured_quantity_precision" => "Введіть не більше :decimals знаків після коми.", + "measured_settings_measure" => "Невідомий спосіб продажу.", + "measured_settings_price_unit" => "Одиниця ціни не відповідає способу продажу.", + "measured_settings_price_per" => "Неприпустима база ціни: не більше одного кілограма, метра чи літра або за 1, 1 000 чи 1 000 000 для кількості.", + "measured_settings_step" => "Крок кількості має бути цілим числом не менше 1.", + "measured_settings_min" => "Мінімальна кількість має бути не меншою за один крок.", + "measured_settings_max" => "Максимальна кількість має бути не меншою за мінімальну і не більшою за 2 000 000 000 для товарів із залишком.", + "measured_settings_tolerance" => "Допуск має бути від 0 до 10000 базисних пунктів.", + "measured_settings_decimals" => "Кількість знаків після коми при введенні має бути від 0 до 6.", + "measured_settings_rounding" => "Округлення має бути вгору, до найближчого або вниз.", + "measured_change_refused" => "Спосіб продажу не можна змінити, поки товар має залишок або замовлення. Збережені кількості змінили б своє значення.", + "measured_deposit_refused" => "Товари, що продаються на вагу, за довжиною, об'ємом або кількістю, не можуть мати застави за тару.", + "repeat_measure_changed" => "Товар тепер продається інакше, ніж у вихідному замовленні. Видаліть його та додайте знову.", + "order_stock_shortage" => "Товари замовлення не збережено: недостатньо залишку для змінених кількостей.", + "actual_quantity" => "Фактично", + "actual_quantity_help" => "Кількість, фактично відрізана або зважена. За нею рядок оплачується, відправляється та списується із залишку.", + "actual_quantity_save" => "Зберегти фактичну", + "actual_quantity_saved" => "Фактичну кількість збережено.", + "actual_quantity_over_tolerance" => "Покупець погодився на більшу кількість", + "actual_quantity_tolerance_exceeded" => "Фактична кількість перевищує дозволений допуск. Підтвердьте, що покупець погодився.", + "actual_quantity_forbidden" => "Вам не дозволено перевищувати допуск.", + "actual_quantity_line" => "Рядок замовлення не знайдено.", + "actual_quantity_piece" => "Фактична кількість застосовується до товарів, що продаються на вагу, за довжиною, об'ємом або кількістю.", + "actual_quantity_order_closed" => "Замовлення скасовано або видалено.", + "actual_quantity_shipped" => "Рядок уже відправлено. Натомість оформіть повернення товару або коштів.", + "actual_quantity_conflict" => "Цю операцію вже виконано з іншою кількістю.", + "actual_quantity_unavailable" => "Для фактичної кількості потрібні останні міграції sCommerce.", + "actual_quantity_stock" => "Недостатньо залишку для фактичної кількості.", + "permission_order_actual_over_tolerance" => "Перевищення допуску фактичної кількості", ]; diff --git a/module/sCommerceModule.php b/module/sCommerceModule.php index 52775d1..3b93aa5 100644 --- a/module/sCommerceModule.php +++ b/module/sCommerceModule.php @@ -334,14 +334,16 @@ foreach ($order->products as $product) { if (isset($product['id'])) { $productId = (int) $product['id']; - $quantity = max(1, (int) ($product['quantity'] ?? 1)); + // A measured line (2530 mm of cable) is one unit sold. + $measured = isset($product['measure']); + $quantity = $measured ? 1 : max(1, (int) ($product['quantity'] ?? 1)); $price = isset($product['priceNumber']) && is_numeric($product['priceNumber']) ? (float) $product['priceNumber'] : sCommerce::convertPriceNumber($product['price'] ?? 0, $order->currency, $order->currency); $productStats[$productId] = $productStats[$productId] ?? ['orders' => 0, 'revenue' => 0.0]; $productStats[$productId]['orders'] += $quantity; - $productStats[$productId]['revenue'] += $price * $quantity; + $productStats[$productId]['revenue'] += $measured ? \Seiger\sCommerce\Services\MeasuredQuantity::lineSum($product, $price) : $price * $quantity; } } } @@ -647,6 +649,11 @@ ? \Seiger\sCommerce\Services\OrderPaymentAction::issueNonce((int)$item->id, 0, \Seiger\sCommerce\Controllers\OrderShipmentController::NONCE_ACTION) : null, ]; } + $data['actualQuantityNonce'] = null; + if ($item->exists && !isset($data['repeatSourceId']) && evo()->hasPermission('exec_module') + && collect(is_array($item->products) ? $item->products : [])->contains(static fn ($line) => is_array($line) && isset($line['measure']))) { + $data['actualQuantityNonce'] = \Seiger\sCommerce\Services\OrderPaymentAction::issueNonce((int)$item->id, 0, \Seiger\sCommerce\Controllers\OrderActualQuantityController::NONCE_ACTION); + } $_SESSION['itemaction'] = $item->exists ? 'Editing a Order of #' . $item->id : 'Creating an Order'; $_SESSION['itemname'] = __('sCommerce::global.title'); break; @@ -675,6 +682,9 @@ case "orderShipmentSave": (new \Seiger\sCommerce\Controllers\OrderShipmentController())->handle(request())->send(); exit; + case "orderActualQuantitySave": + (new \Seiger\sCommerce\Controllers\OrderActualQuantityController())->handle(request())->send(); + exit; case "orderRepeatSave": (new \Seiger\sCommerce\Controllers\OrderRepeatController())->handle(request())->send(); exit; @@ -682,6 +692,7 @@ $requestId = request()->integer('i'); $item = sOrder::find($requestId); $paymentNotice = null; + $quantityNotice = null; if ($item) { $scope = EventScope::begin('manager', (int)evo()->getLoginUserID('mgr')); @@ -764,25 +775,20 @@ } } - // Update products if provided + // Update products if provided; identity, measured rules and the cost are recalculated on the server. + $productsChanged = false; if (request()->has('products_data')) { $productsDataJson = request()->string('products_data')->value(); $productsData = json_decode($productsDataJson, true); if (json_last_error() === JSON_ERROR_NONE && is_array($productsData)) { - $productsData = \Seiger\sCommerce\Services\ContainerDeposit::applyToLines(\Seiger\sCommerce\Services\OrderTaxes::applyRetailExcise( - app(\Seiger\sCommerce\Services\OrderLineIdentity::class)->fillMissing($productsData, (string)$item->currency) - )); - $oldProducts = $item->products; - $item->products = $productsData; - - // Recalculate order cost; delivery cost and other non-product amounts are kept. - $item->cost = \Seiger\sCommerce\Services\OrderCostRecalculator::recalculate( - $item->getOriginal('cost') ?? 0, is_array($oldProducts) ? $oldProducts : [], $productsData, (string) $item->currency - ); - - // Add history entry if products changed - if (json_encode($oldProducts) !== json_encode($productsData)) { + try { + $productsChanged = (new \Seiger\sCommerce\Services\OrderLineEditor())->apply($item, $productsData); + } catch (\Seiger\sCommerce\Services\MeasuredQuantityException $e) { + $quantityNotice = [$e->messageKey, (string)(array_values($e->replace)[0] ?? '')]; + } + + if ($productsChanged) { $history[] = [ 'products_updated' => true, 'timestamp' => now()->toDateTimeString(), @@ -793,12 +799,26 @@ } $item->history = $history; - $item->update(); + $saved = true; + try { + // Locally committed stock follows changed, added and removed lines. + \Illuminate\Support\Facades\DB::transaction(function () use ($item, $productsChanged): void { + $item->update(); + if ($productsChanged) { + (new \Seiger\sCommerce\Services\StockCommitter())->adjust($item); + } + }); + } catch (\Seiger\sCommerce\Services\StockShortageException) { + $saved = false; + $quantityNotice = ['order_stock_shortage', '']; + } $_SESSION['itemaction'] = 'Saving a Order of #' . $item->id; $_SESSION['itemname'] = __('sCommerce::global.title'); $scope->end(); - evo()->invokeEvent('sCommerceAfterOrderSave', compact('item')); + if ($saved) { + evo()->invokeEvent('sCommerceAfterOrderSave', compact('item')); + } } $back = str_replace('&i=0', '&i=' . ($item->id ?? 0), (request()->back ?? '&get=orders')); @@ -806,6 +826,9 @@ // Flash data does not survive the EVO session proxy sync, so the notice travels in the URL. $back .= '&payment_notice=' . $paymentNotice; } + if ($quantityNotice !== null) { + $back .= '&quantity_notice=' . urlencode($quantityNotice[0]) . '¬ice_arg=' . urlencode(mb_substr($quantityNotice[1], 0, 40)); + } return header('Location: ' . sCommerce::moduleUrl() . $back); case "orderDelete": $requestId = request()->integer('i'); @@ -1252,6 +1275,18 @@ function () use ($domains, $resources, $sCommerceController) { $product->tmplvars = json_encode(request()->input('tmplvars', []), JSON_UNESCAPED_UNICODE); $product->votes = json_encode($votes, JSON_UNESCAPED_UNICODE); $product->mode = request()->integer('mode'); + // Measured settings are validated together; on refusal the rest of the product is still saved. + // Forms without the "Sold by" block leave them unchanged. + $quantityNotice = null; + if (request()->has('measure') && Schema::hasColumn('s_products', 'measure')) { + try { + foreach (\Seiger\sCommerce\Services\MeasuredQuantity::productSettings(request()->only(\Seiger\sCommerce\Services\MeasuredQuantity::PRODUCT_COLUMNS), $product) as $column => $value) { + $product->{$column} = $value; + } + } catch (\Seiger\sCommerce\Services\MeasuredQuantityException $e) { + $quantityNotice = $e->messageKey; + } + } $product->stockSource = 'manager'; EventScope::run('manager', (int)evo()->getLoginUserID('mgr'), fn () => $product->save()); @@ -1317,6 +1352,9 @@ function () use ($domains, $resources, $sCommerceController) { $_SESSION['itemaction'] = 'Saving Product'; $_SESSION['itemname'] = $product->title; $back = str_replace('&i=0', '&i=' . $product->id, (request()->back ?? '&get=product')); + if ($quantityNotice !== null) { + $back .= '&quantity_notice=' . urlencode($quantityNotice); + } evo()->invokeEvent('sCommerceAfterProductSave', compact('product', 'text')); return header('Location: ' . sCommerce::moduleUrl() . $back); case "productDuplicate": @@ -2610,6 +2648,9 @@ function () use ($domains, $resources, $sCommerceController) { $_SESSION['itemaction'] = 'Saving Settings'; session()->flash('success', __('sCommerce::global.settings_save_success')); $back = request()->back ?? '&get=settings'; + if (($settingsErrors = $sCommerceController->settingsErrors()) !== []) { + $back .= '&quantity_notice=' . urlencode($settingsErrors[0]); + } return header('Location: ' . sCommerce::moduleUrl() . $back); } diff --git a/src/Cart/sCart.php b/src/Cart/sCart.php index b698bea..baa6418 100644 --- a/src/Cart/sCart.php +++ b/src/Cart/sCart.php @@ -7,7 +7,10 @@ use Seiger\sCommerce\Models\sAttribute; use Seiger\sCommerce\Models\sProduct; use Seiger\sCommerce\Services\ContainerDeposit; +use Seiger\sCommerce\Services\MeasuredQuantity; +use Seiger\sCommerce\Services\MeasuredQuantityException; use Seiger\sCommerce\Services\OrderLineIdentity; +use Seiger\sCommerce\Services\QuantityRules; use Seiger\sCommerce\Services\StockResolver; use Seiger\sCommerce\Services\VatDisplay; use Seiger\sCommerce\Services\sPriceResolver; @@ -102,6 +105,11 @@ public function clearVatDisplay(): void /** * Add a product to the cart. * + * Quantities are whole numbers: pieces, or base units (grams, millimetres, millilitres, + * items) for products sold by measure. A request `amount` in the price unit (`2.53` metres) + * takes precedence over `quantity` for those products; their quantity is fitted to the + * minimum, step and stock, and the response reports an adjustment with `quantityAdjusted`. + * * @param int $productId The ID of the product to add. * @param int $optionId The ID option associated with the product. * @param int $quantity The quantity of the product to add. @@ -133,12 +141,29 @@ public function addProduct(int $productId = 0, int $optionId = 0, int $quantity ]; } + $rules = MeasuredQuantity::rules($product); + $adjusted = false; + if ($rules->isMeasured()) { + $requested = $quantity; + if (is_scalar($amount = request()->input('amount')) && trim((string)$amount) !== '') { + try { + $requested = MeasuredQuantity::parseAmount((string)$amount, $rules); + } catch (MeasuredQuantityException $e) { + return ['success' => false, 'trigger' => $trigger, 'message' => $e->translated()]; + } + } + $quantity = MeasuredQuantity::normalize($requested, $rules); + $adjusted = $quantity !== $requested; + } + $this->storeQuantity($productId, $optionId, $quantity); // Virtual and service lines are not counted against inventory. if (sCommerce::config('product.inventory_on', 0) && $this->tracksInventory($product, $optionId)) { $available = app(StockResolver::class)->available($product, 0, ['optionId' => $optionId, 'quantity' => $quantity]); - if ($available < 0) { + // Measured stock is capped in whole steps and must cover the minimum. + $capped = $rules->isMeasured() ? MeasuredQuantity::normalize($quantity, $rules, MeasuredQuantity::MODE_CLAMP, $available) : null; + if ($available < 0 || ($capped !== null && $capped < $rules->min)) { $message = __('sCommerce::global.product_title_is_out_of_stock', ['title' => $product->title]); Log::channel('scommerce')->alert('sCart - ' . $message); @@ -147,6 +172,9 @@ public function addProduct(int $productId = 0, int $optionId = 0, int $quantity 'trigger' => $trigger, 'message' => $message, ]; + } elseif ($capped !== null) { + $adjusted = $adjusted || $capped !== $quantity; + $quantity = $capped; } else { $quantity = min($quantity, $available); } @@ -163,15 +191,24 @@ public function addProduct(int $productId = 0, int $optionId = 0, int $quantity $message = __('sCommerce::global.product_with_title', ['title' => $product->title]) . ' ' . __('sCommerce::global.added_to_cart') . '.'; break; } + if ($adjusted && $trigger !== 'quantity') { + $message .= ' ' . __('sCommerce::global.changed_quantity') . ': ' . MeasuredQuantity::label($quantity, $rules) . '.'; + } - return [ + $response = [ 'success' => true, 'trigger' => $trigger, 'message' => $message, 'product' => array_merge($this->getProductFields($product, $pricing = $this->resolveProductPricing($product, $optionId)), - $this->vatPricing($product, $pricing, $optionId), compact('quantity')), + $this->vatPricing($product, $pricing, $optionId), compact('quantity'), + MeasuredQuantity::lineFields($rules, $quantity, (string)($pricing['price'] ?? ''))), 'miniCart' => $this->getMiniCart(), ]; + if ($adjusted) { + $response['quantityAdjusted'] = true; + } + + return $response; } /** @@ -251,6 +288,9 @@ public function setContainerExchange(int $productId = 0, ?int $optionId = null, * retail excise included in the prices (`retailExciseAmount`, `sumRetailExcise`, `totalRetailExcise`). * Lines with a returnable container deposit carry the deposit keys of {@see ContainerDeposit}; * `totalSum` is what the customer pays: `totalGoods` plus `totalDeposit`. + * 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}). * * @return array{totalSum: float, totalSumFormatted: string, totalSumNet: float, totalSumNetFormatted: string, totalSumDisplay: float, totalSumDisplayFormatted: string, totalRetailExcise: float, totalRetailExciseFormatted: string, vatDisplay: string, items: array>} */ @@ -267,20 +307,23 @@ public function getMiniCart(): array $products = sCommerce::getProducts($productIds); foreach ($products as $product) { + $rules = MeasuredQuantity::rules($product); foreach ($this->cartData[$product->id] as $optionId => $cartItem) { - $quantity = $this->getCartItemQuantity($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 = $price * $quantity; + $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; - $items[] = array_merge( + $item = array_merge( $this->getProductFields($product, $pricing), app(OrderLineIdentity::class)->snapshot($product, (int)$optionId), $vat, @@ -290,6 +333,10 @@ public function getMiniCart(): array '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)); + } + $items[] = $item; $totalSum += $lineSum; $totalNet += $sumNetAsFloat; $totalExcise += $sumExciseAsFloat; @@ -397,9 +444,12 @@ private function storeQuantity(int $productId, int $optionId, int $quantity): vo : $quantity; } - private function getCartItemQuantity(mixed $cartItem): int + /** + * Stored quantity of a cart line, at least `$min` (the minimum of a measured product). + */ + private function getCartItemQuantity(mixed $cartItem, int $min = 1): int { - return is_array($cartItem) ? max(1, (int)($cartItem['quantity'] ?? 1)) : max(1, (int)$cartItem); + return is_array($cartItem) ? max($min, (int)($cartItem['quantity'] ?? 1)) : max($min, (int)$cartItem); } private function resolveProductPriceMode(object $product, int $optionId = 0): string diff --git a/src/Checkout/sCheckout.php b/src/Checkout/sCheckout.php index 79867d1..1d34fdf 100644 --- a/src/Checkout/sCheckout.php +++ b/src/Checkout/sCheckout.php @@ -17,6 +17,8 @@ use Seiger\sCommerce\Payment\PaymentFlow; use Seiger\sCommerce\Payment\PaymentSchedule; use Seiger\sCommerce\Services\ContainerDeposit; +use Seiger\sCommerce\Services\MeasuredQuantity; +use Seiger\sCommerce\Services\MeasuredQuantityException; use Seiger\sCommerce\Services\OrderLineIdentity; use Seiger\sCommerce\Services\OrderTaxes; use Seiger\sCommerce\Services\VatDisplay; @@ -647,7 +649,9 @@ public function processOrder(): array * The trusted checkout context is resolved before the order is built. * * @param array $data The data from the request, including productId, optionId, quantity, exchange (empty returnable - * containers handed over), phone, and/or email. + * containers handed over), phone, and/or email. For products sold by measure, `quantity` is in + * base units and an `amount` in the price unit (`2.53` metres) takes precedence; the quantity + * is fitted to the minimum and step of the product. * @return array An array containing the result of the operation with success status and a message. */ public static function quickOrder(array $data) @@ -715,8 +719,28 @@ public static function quickOrder(array $data) $quantity = $data['quantity'] ?? 1; $optionId = max(0, (int)($data['optionId'] ?? 0)); + $rules = MeasuredQuantity::rules($product); + if ($rules->isMeasured()) { + try { + if (is_scalar($data['amount'] ?? null) && trim((string)$data['amount']) !== '') { + $quantity = MeasuredQuantity::parseAmount((string)$data['amount'], $rules); + } + } catch (MeasuredQuantityException $e) { + return [ + 'success' => false, + 'product' => (int)$data['productId'], + 'message' => $e->translated(), + ]; + } + $quantity = MeasuredQuantity::normalize($quantity, $rules); + } $pricing = static::resolveProductPricing($product, $optionId); + $price = (float)$pricing['priceAsFloat']; + $cost = $rules->isMeasured() + ? MeasuredQuantity::lineTotalMinor(MeasuredQuantity::scalePrice($price), $quantity, $rules->factor) / 100 + : $price * $quantity; + $productData = [ 'id' => $product->id, 'title' => $product->title, @@ -736,7 +760,18 @@ public static function quickOrder(array $data) $productData['priceMode'] = $pricing['priceMode']; } $productData = array_merge($productData, app(OrderLineIdentity::class)->snapshot($product, $optionId)); - $productData = array_merge($productData, $product->physicalPayload()); + + // Units exchanged for empty returnable containers carry no new deposit. + $productData = array_merge($productData, ContainerDeposit::lineFields(ContainerDeposit::unit($product, $optionId), $quantity, max(0, (int)($data['exchange'] ?? 0)))); + $cost += (float)($productData['deposit_amount'] ?? 0); + + $productData = array_merge($productData, $physical = $product->physicalPayload()); + $productData += MeasuredQuantity::lineFields($rules, $quantity, (string)($pricing['price'] ?? ''), (int)($physical['weight'] ?? 0)); + if ($rules->isMeasured()) { + $productData['sumAsFloat'] = round($cost, 3); + $productData['sum'] = sCommerce::convertPrice($productData['sumAsFloat']); + } + $productsData = [$productData]; } diff --git a/src/Controllers/OrderActualQuantityController.php b/src/Controllers/OrderActualQuantityController.php new file mode 100644 index 0000000..d7156d9 --- /dev/null +++ b/src/Controllers/OrderActualQuantityController.php @@ -0,0 +1,98 @@ +getRealMethod() !== 'POST' || !$request->isMethod('POST')) { + return new JsonResponse(['success' => false, 'message' => __('sCommerce::global.bulk_status_post_only')], 405, ['Allow' => 'POST']); + } + $managerId = (int)evo()->getLoginUserID('mgr'); + if (empty($_SESSION['mgrValidated']) || $managerId < 1 || !evo()->hasPermission('exec_module')) { + return $this->fail('payment_action_forbidden', 403); + } + $token = $request->input('_token', $request->header('X-CSRF-TOKEN')); + $sessionToken = $_SESSION['_token'] ?? null; + if (!is_string($token) || !is_string($sessionToken) || $sessionToken === '' || !hash_equals($sessionToken, $token)) { + return $this->fail('bulk_status_session_expired', 403); + } + + $orderId = is_int($order = $request->input('order')) || is_string($order) + ? filter_var($order, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) : false; + $nonce = $request->input('nonce'); + $lineKey = $request->input('line_key'); + $amount = $request->input('amount'); + if ($orderId === false || !is_string($nonce) || !is_string($lineKey) || $lineKey === '' || strlen($lineKey) > 64 || !is_string($amount)) { + return $this->fail('measured_quantity_invalid', 422); + } + if (!OrderPaymentAction::nonceMatches($nonce, $orderId, 0, self::NONCE_ACTION)) { + return $this->fail('bulk_status_session_expired', 403); + } + $overTolerance = filter_var($request->input('over_tolerance', false), FILTER_VALIDATE_BOOL); + if ($overTolerance && !evo()->hasPermission(self::PERMISSION_OVER_TOLERANCE)) { + return $this->fail('actual_quantity_forbidden', 403); + } + $order = sOrder::query()->find($orderId); + $line = null; + foreach ($order && is_array($order->products) ? $order->products : [] as $candidate) { + if (is_array($candidate) && ($candidate['line_key'] ?? null) === $lineKey) { + $line = $candidate; + break; + } + } + if (!$order || !$line) { + return $this->fail('actual_quantity_line', 422); + } + + try { + $actual = MeasuredQuantity::parseAmount($amount, MeasuredQuantity::fromLine($line)); + $this->service->set($order, $lineKey, $actual, 'manager', $managerId, + 'manager:' . hash('sha256', $nonce . '|' . $lineKey . '|' . $actual . '|' . ($overTolerance ? 1 : 0)), $overTolerance); + } catch (MeasuredQuantityException $exception) { + return new JsonResponse(['success' => false, 'message' => $exception->translated()], $exception->status); + } catch (StockShortageException) { + return $this->fail('actual_quantity_stock', 409); + } catch (\Throwable $exception) { + Log::channel('scommerce')->error('sCommerce actual quantity failed', ['order_id' => $orderId, 'exception' => $exception]); + return $this->fail('bulk_status_error', 500); + } + + return new JsonResponse(['success' => true, 'message' => __('sCommerce::global.actual_quantity_saved')]); + } + + private function fail(string $key, int $status): JsonResponse + { + return new JsonResponse(['success' => false, 'message' => __('sCommerce::global.' . $key)], $status); + } +} diff --git a/src/Controllers/OrderRepeatController.php b/src/Controllers/OrderRepeatController.php index f8ddf0d..434d491 100644 --- a/src/Controllers/OrderRepeatController.php +++ b/src/Controllers/OrderRepeatController.php @@ -8,6 +8,10 @@ use Seiger\sCommerce\Facades\sCommerce; use Seiger\sCommerce\Models\sOrder; use Seiger\sCommerce\Services\ContainerDeposit; +use Seiger\sCommerce\Services\MeasuredQuantity; +use Seiger\sCommerce\Services\MeasuredQuantityException; +use Seiger\sCommerce\Services\OrderLineEditor; +use Seiger\sCommerce\Services\QuantityRules; use Seiger\sCommerce\Services\OrderLineIdentity; use Seiger\sCommerce\Services\OrderTaxes; use Seiger\sCommerce\Services\OrderRepeater; @@ -24,6 +28,8 @@ final class OrderRepeatController * * Order/payment statuses and operational metadata are never accepted from POST. * Lines without a `line_key` receive their identity from the current product. + * Measured lines are validated with the current rules of their product, because a repeat is + * a new order; a product whose sales measure changed since the source order is refused. * * @param Request $request Manager form with current CSRF and draft token. * @return JsonResponse Success redirect or a non-mutating validation/error response. @@ -74,11 +80,22 @@ private function process(Request $request, int $managerId): JsonResponse $cost = 0.0; foreach ($products as &$product) { if (!is_array($product)) return $this->error('repeat_invalid', 422); + $product = array_diff_key($product, array_flip(array_merge(MeasuredQuantity::LINE_KEYS, MeasuredQuantity::ACTUAL_KEYS, MeasuredQuantity::DISPLAY_KEYS))) + + array_intersect_key($this->storedLine($source, $product), array_flip(['measure'])); + $rules = $this->currentRules($product); + if ($rules === false) return $this->error('repeat_measure_changed', 422); $quantity = $product['quantity'] ?? null; if (!(is_int($quantity) || is_string($quantity)) - || !($quantity = filter_var($quantity, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 1000000]]))) { + || !($quantity = filter_var($quantity, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => $rules?->max ?? 1000000]]))) { return $this->error('repeat_invalid', 422); } + if ($rules !== null) { + try { + MeasuredQuantity::normalize($quantity, $rules, MeasuredQuantity::MODE_STRICT); + } catch (MeasuredQuantityException $exception) { + return new JsonResponse(['message' => $exception->translated()], 422); + } + } foreach (['title', 'sku', 'link', 'coverSrc'] as $key) { if (!is_string($product[$key] ?? null) || strlen($product[$key]) > 10000) { return $this->error('repeat_invalid', 422); @@ -101,7 +118,13 @@ private function process(Request $request, int $managerId): JsonResponse if (!is_finite($price) || $price < 0 || $price > 9999999) return $this->error('repeat_invalid', 422); $product['quantity'] = $quantity; $product['price'] = $product['priceNumber'] = $price; - $cost += $price * $quantity; + unset($product['measure']); + if ($rules !== null) { + $product = $rules->lineKeys() + $product; + $product = OrderLineEditor::refreshSum(MeasuredQuantity::refreshLine($product, $source->lang ?: null), (string)$source->currency); + $product['unit_price_label'] = MeasuredQuantity::unitPriceLabel(sCommerce::convertPrice($price, $source->currency), $rules, $source->lang ?: null); + } + $cost += MeasuredQuantity::lineSum($product, $price); } unset($product); if (!is_finite($cost) || $cost > 9999999) return $this->error('repeat_invalid', 422); @@ -144,6 +167,40 @@ private function process(Request $request, int $managerId): JsonResponse 'url' => sCommerce::moduleUrl() . '&get=order&i=' . $result['item']->id]); } + /** + * Stored source line with the same `line_key`, or an empty array. + * + * @return array + */ + private function storedLine(sOrder $source, array $line): array + { + $key = $line['line_key'] ?? null; + foreach (is_string($key) && $key !== '' && is_array($source->products) ? $source->products : [] as $stored) { + if (is_array($stored) && ($stored['line_key'] ?? null) === $key) { + return $stored; + } + } + + return []; + } + + /** + * Current rules of a measured line's product; null for piece lines, false when the product's + * measure differs from the stored line. + */ + private function currentRules(array $line): QuantityRules|false|null + { + $productId = (int)($line['product_id'] ?? $line['id'] ?? 0); + $product = $productId > 0 ? sCommerce::getProduct($productId) : null; + $rules = $product && (int)($product->id ?? 0) > 0 ? MeasuredQuantity::rules($product) : MeasuredQuantity::piece(); + $stored = is_string($line['measure'] ?? null) ? $line['measure'] : MeasuredQuantity::PIECE; + if ($stored !== $rules->measure && (isset($line['line_key']) || $stored !== MeasuredQuantity::PIECE)) { + return false; + } + + return $rules->isMeasured() ? $rules : null; + } + /** * Return localized feedback without exposing internal errors or customer data. * diff --git a/src/Controllers/sCommerceController.php b/src/Controllers/sCommerceController.php index 517e464..c0a6178 100644 --- a/src/Controllers/sCommerceController.php +++ b/src/Controllers/sCommerceController.php @@ -17,6 +17,7 @@ use Seiger\sCommerce\Models\sAttributeValue; use Seiger\sCommerce\Models\sCategory; use Seiger\sCommerce\Models\sProduct; +use Seiger\sCommerce\Services\MeasuredQuantity; use Seiger\sLang\Models\sLangContent; class sCommerceController @@ -38,6 +39,9 @@ class sCommerceController protected static array $listCategoriesCache = []; protected $data = []; + + /** @var list Translation keys of settings refused by the last save. */ + private array $settingsErrors = []; protected $categories = []; protected $productIds = []; protected array $categoryLangCache = []; @@ -196,6 +200,12 @@ public function updateFileConfigs(): bool } } + // Invalid measured quantity defaults keep their stored values. + if (isset($config['product']) && is_array($config['product'])) { + $previous = sCommerce::config('product', []); + $config['product'] = MeasuredQuantity::sanitizeSettings($config['product'], is_array($previous) ? $previous : [], $this->settingsErrors); + } + // Preparation of deadlines with data $string = 'dataToString($config) . ';'; @@ -207,6 +217,17 @@ public function updateFileConfigs(): bool return true; } + /** + * Translation keys of settings refused by the last {@see self::updateFileConfigs()}. + * + * @return list + * @since 1.5.0 + */ + public function settingsErrors(): array + { + return $this->settingsErrors; + } + /** * Retrieve the default language from the configuration. * diff --git a/src/Integration/GoogleMerchantFeed.php b/src/Integration/GoogleMerchantFeed.php index 5ef4118..db1d304 100644 --- a/src/Integration/GoogleMerchantFeed.php +++ b/src/Integration/GoogleMerchantFeed.php @@ -12,6 +12,7 @@ use Seiger\sCommerce\Controllers\sCommerceController; use Seiger\sCommerce\Facades\sCommerce; use Seiger\sCommerce\Models\sProduct; +use Seiger\sCommerce\Services\MeasuredQuantity; use Seiger\sGallery\Models\sGalleryModel as sGalleryModel; use Seiger\sLang\Facades\sLang; use Seiger\sTask\Models\sTaskModel; @@ -1466,6 +1467,12 @@ protected function writeItem(\XMLWriter $writer, sProduct $product, array $confi $writer->writeElement('g:sale_price', $salePrice); } + // Goods sold by measure: the price refers to the price basis (EU unit price display). + if (($unitPricing = self::unitPricingMeasure($product)) !== null) { + $writer->writeElement('g:unit_pricing_measure', $unitPricing); + $writer->writeElement('g:unit_pricing_base_measure', $unitPricing); + } + $writer->writeElement('g:condition', 'new'); $writer->writeElement('g:brand', $this->sanitizeText($brand, 70)); $writer->writeElement('g:mpn', $this->sanitizeText($product->sku ?: $identifier, 70)); @@ -1486,6 +1493,27 @@ protected function writeItem(\XMLWriter $writer, sProduct $product, array $confi $writer->endElement(); // item } + /** + * Google unit pricing measure of a product sold by weight, length or volume: the price basis + * in the largest unit that keeps it whole (`1m`, `1kg`, `100g`, `100ml`); null otherwise. + * + * @since 1.5.0 + */ + public static function unitPricingMeasure(object $product): ?string + { + $rules = MeasuredQuantity::rules($product); + if (!in_array($rules->measure, ['weight', 'length', 'volume'], true)) { + return null; + } + foreach (MeasuredQuantity::units($rules->measure) as [$unit, $factor]) { + if ($rules->factor % $factor === 0) { + return intdiv($rules->factor, $factor) . $unit; + } + } + + return null; + } + /** * Detect placeholder "noimage" URLs (local or absolute). * diff --git a/src/Integration/ImportExportCSV.php b/src/Integration/ImportExportCSV.php index 4d4db5b..a5391da 100644 --- a/src/Integration/ImportExportCSV.php +++ b/src/Integration/ImportExportCSV.php @@ -8,6 +8,8 @@ use Seiger\sCommerce\Models\sAttributeValue; use Seiger\sTask\Models\sTaskModel; use Seiger\sCommerce\Models\sProduct; +use Seiger\sCommerce\Services\MeasuredQuantity; +use Seiger\sCommerce\Services\MeasuredQuantityException; use Seiger\sCommerce\Models\sProductTranslate; use Seiger\sGallery\Facades\sGallery; use Seiger\sGallery\Models\sGalleryModel; @@ -342,6 +344,9 @@ public function taskExport(sTaskModel $task, array $opt = []): void $a[$field] = implode('||', $primaryCategories); } break; + case 'measure': + $a[$field] = sProduct::MEASURE_ALIASES[(int)($p?->measure ?? 0)] ?? MeasuredQuantity::PIECE; + break; case 'cover': $a[$field] = $this->sanitizeCsvValue($this->normalizeCsvMediaPath($p?->{$field} ?? '')); break; @@ -674,6 +679,13 @@ private function buildHeaderRow(bool $useMultilang, sCommerceController $sCommer // 'updated_at' => __('sCommerce::global.created'), ]; + // Sales measure; quantities and inventory of measured products are in base units (g, mm, ml, items). + if ($this->hasMeasuredColumns()) { + foreach (MeasuredQuantity::PRODUCT_COLUMNS as $column) { + $product[$column] = __('sCommerce::global.' . $column); + } + } + // Product translations if ($useMultilang) { foreach ($this->csvLanguagesList($sCommerceController) as $lang) { @@ -958,6 +970,9 @@ private function createTempTable(string $tableName): void $table->boolean('published')->default(1); $table->boolean('availability')->default(0); $table->integer('inventory')->default(0); + foreach (MeasuredQuantity::PRODUCT_COLUMNS as $column) { + $table->string($column, 32)->nullable(); + } $table->decimal('price_regular', 10, 3)->default(0.000); $table->decimal('price_special', 10, 3)->nullable(); $table->string('currency', 3)->default('UAH'); @@ -1036,6 +1051,8 @@ private function loadCsvToTempTable(string $filePath, string $tableName, array $ 'published' => isset($data['published']) && $data['published'] !== '' ? (int)$data['published'] : 1, 'availability' => isset($data['availability']) && $data['availability'] !== '' ? (int)$data['availability'] : 0, 'inventory' => isset($data['inventory']) && $data['inventory'] !== '' ? (int)$data['inventory'] : 0, + ...array_map(static fn ($value) => is_scalar($value) ? mb_substr(trim((string)$value), 0, 32) : null, + array_merge(array_fill_keys(MeasuredQuantity::PRODUCT_COLUMNS, null), array_intersect_key($data, array_flip(MeasuredQuantity::PRODUCT_COLUMNS)))), 'price_regular' => isset($data['price_regular']) && $data['price_regular'] !== '' ? (float)$data['price_regular'] : 0.00, 'price_special' => isset($data['price_special']) && $data['price_special'] !== '' ? (float)$data['price_special'] : null, 'currency' => $data['currency'] ?? 'UAH', @@ -1071,6 +1088,39 @@ private function loadCsvToTempTable(string $filePath, string $tableName, array $ fclose($fh); } + /** + * Whether `s_products` has the sales measure columns. + */ + private function hasMeasuredColumns(): bool + { + return Schema::hasColumn('s_products', 'measure'); + } + + /** + * Validate and set the sales measure columns present in a row; missing columns keep the + * product's values. Inventory of a measured product is in base units. + * + * @throws \RuntimeException When the measure settings are refused. + */ + private function applyMeasuredColumns(sProduct $product, array $data): void + { + $given = array_filter(array_intersect_key($data, array_flip(MeasuredQuantity::PRODUCT_COLUMNS)), static fn ($value) => $value !== null); + if ($given === [] || !$this->hasMeasuredColumns()) { + return; + } + $input = []; + foreach (MeasuredQuantity::PRODUCT_COLUMNS as $column) { + $input[$column] = $product->getAttribute($column); + } + try { + foreach (MeasuredQuantity::productSettings(array_replace($input, $given), $product) as $column => $value) { + $product->setAttribute($column, $value); + } + } catch (MeasuredQuantityException $exception) { + throw new \RuntimeException('Product ' . ($product->sku ?: $product->id) . ': ' . $exception->translated(), 0, $exception); + } + } + /** * Prepare attributes data for temporary table. * @@ -1294,6 +1344,7 @@ private function processProductRow(array $data): array if (isset($data['height'])) $product->setPhysicalValue('height', $data['height']); if (isset($data['length'])) $product->setPhysicalValue('length', $data['length']); if (isset($data['volume'])) $product->setPhysicalValue('volume', $data['volume']); + $this->applyMeasuredColumns($product, $data); if (isset($data['cover'])) $product->cover = trim($data['cover'] ?? '/assets/site/noimage.png'); if (isset($data['relevants'])) $product->relevants = json_encode(($data['relevants'] ?? []), JSON_UNESCAPED_UNICODE); if (isset($data['similar'])) $product->similar = json_encode(($data['similar'] ?? []), JSON_UNESCAPED_UNICODE); diff --git a/src/Models/Concerns/HasPhysicalAttributes.php b/src/Models/Concerns/HasPhysicalAttributes.php index 85def64..1ab5eb4 100644 --- a/src/Models/Concerns/HasPhysicalAttributes.php +++ b/src/Models/Concerns/HasPhysicalAttributes.php @@ -188,6 +188,37 @@ public static function physicalLabel(string $alias, int $value): string return (string) $value; } + /** + * Same as {@see self::physicalLabel()}, with the unit symbol and decimal separator of the language. + * + * @since 1.5.0 + * @param string $alias One of the five physical attribute aliases. + * @param int $value Measurement in grams, millimetres or millilitres. + * @param string|null $locale Language; the current one when null. + * @return string Label such as `1 kg` or `1 кг`. + */ + public static function physicalLabelLocalized(string $alias, int $value, ?string $locale = null): string + { + foreach (self::PHYSICAL_UNITS[$alias] ?? [] as [$unit, $factor]) { + if ($value >= $factor || $factor === 1) { + return \Seiger\sCommerce\Services\MeasuredQuantity::formatAmount($value, $factor, $unit, 0, $locale); + } + } + return (string) $value; + } + + /** + * Display units of a measurement, largest first. + * + * @since 1.5.0 + * @param string $alias `weight`, `width`, `height`, `length` or `volume`. + * @return list Unit code and base units per unit; empty for other aliases. + */ + public static function physicalUnits(string $alias): array + { + return self::PHYSICAL_UNITS[$alias] ?? []; + } + /** * Unit symbol in which a measurement is stored and entered. * diff --git a/src/Models/sOrder.php b/src/Models/sOrder.php index 8ca9b4c..c0eb359 100644 --- a/src/Models/sOrder.php +++ b/src/Models/sOrder.php @@ -18,6 +18,7 @@ use Seiger\sCommerce\Payment\PaymentFlow; use Seiger\sCommerce\Services\OfflineSettlement; use Seiger\sCommerce\Services\OrderReferenceFormatter; +use Seiger\sCommerce\Services\MeasuredQuantity; use Seiger\sCommerce\Services\StockCommitter; /** @@ -399,6 +400,33 @@ private static function statusConstantKeys(string $prefix): array return $list; } + /** + * Largest amount an authorize-then-capture payment may reserve: `cost` plus, for each measured + * line with a tolerance, the line total times the tolerance, rounded half up to cents. + * + * The actual quantity of a cut or weighed line may exceed the ordered one by its tolerance + * ({@see \Seiger\sCommerce\Services\OrderActualQuantity}). Opt-in for payment methods; sCommerce + * does not change how existing methods compute their amount. + * + * @since 1.5.0 + */ + public function authorizationCeiling(): float + { + $extra = 0; + foreach (is_array($this->products) ? $this->products : [] as $line) { + if (!is_array($line) || (int)($line['quantity_tolerance'] ?? 0) <= 0 || !isset($line['measure'])) { + continue; + } + $price = $line['priceNumber'] ?? $line['priceAsFloat'] ?? $line['price'] ?? 0; + $rules = MeasuredQuantity::fromLine($line); + $total = MeasuredQuantity::lineTotalMinor(MeasuredQuantity::scalePrice(is_numeric($price) ? max(0.0, (float)$price) : 0.0), + max(0, (int)($line['quantity'] ?? 0)), $rules->factor); + $extra += intdiv(2 * $total * $rules->tolerance + 10000, 20000); + } + + return round((float)$this->cost + $extra / 100, 2); + } + /** * Whether a column added by a later migration exists; only positive results are cached. * diff --git a/src/Models/sProduct.php b/src/Models/sProduct.php index 479cbd9..3d22cdb 100644 --- a/src/Models/sProduct.php +++ b/src/Models/sProduct.php @@ -13,6 +13,8 @@ use Seiger\sCommerce\Events\EventScope; use Seiger\sCommerce\Events\ProductStockChanged; use Seiger\sCommerce\Facades\sCommerce; +use Seiger\sCommerce\Services\MeasuredQuantity; +use Seiger\sCommerce\Services\QuantityRules; use Seiger\sCommerce\Services\VatDisplay; use Seiger\sGallery\sGallery; @@ -106,6 +108,10 @@ class sProduct extends Model 'height' => 'decimal:4', 'length' => 'decimal:4', 'volume' => 'decimal:4', + 'measure' => 'integer', + 'quantity_step' => 'integer', + 'quantity_min' => 'integer', + 'quantity_tolerance' => 'integer', ]; protected $fillable = ['uuid']; @@ -156,6 +162,27 @@ public function getRatingAttribute(mixed $value): float // const MODE_PREORDER = 9; // Products not yet available but open for early purchase. // const MODE_CUSTOM = 10; // Personalized or tailor-made products created upon request. + /** + * How a product is sold: in pieces, or by weight (grams), length (millimetres), volume + * (millilitres) or count (items, e.g. impressions). See {@see MeasuredQuantity}. + * + * @since 1.5.0 + */ + const MEASURE_PIECE = 0; + const MEASURE_WEIGHT = 1; + const MEASURE_LENGTH = 2; + const MEASURE_VOLUME = 3; + const MEASURE_COUNT = 4; + const MEASURE_ALIASES = [ + self::MEASURE_WEIGHT => 'weight', + self::MEASURE_LENGTH => 'length', + self::MEASURE_VOLUME => 'volume', + self::MEASURE_COUNT => 'count', + ]; + + /** @var array{0: string, 1: QuantityRules}|null Rules memoized with the attributes they came from. */ + private ?array $quantityRulesCache = null; + /** * Return list of availability codes and labels * @@ -223,6 +250,31 @@ public function fulfilment(int $optionId = 0): array return $fulfilment; } + /** + * Whether the product is sold by weight, length, volume or count. + * + * @since 1.5.0 + */ + public function isMeasured(): bool + { + return isset(self::MEASURE_ALIASES[(int)($this->attributes['measure'] ?? 0)]); + } + + /** + * Effective quantity rules: product columns, then the site defaults. Memoized per instance. + * + * @since 1.5.0 + */ + public function quantityRules(): QuantityRules + { + $key = implode('|', array_map(fn ($column) => (string)($this->attributes[$column] ?? ''), [...MeasuredQuantity::PRODUCT_COLUMNS, 'mode'])); + if ($this->quantityRulesCache === null || $this->quantityRulesCache[0] !== $key) { + $this->quantityRulesCache = [$key, MeasuredQuantity::resolve($this)]; + } + + return $this->quantityRulesCache[1]; + } + /** * This method is called when the sProduct class is booted. * It adds a global scope to the query that joins the s_product_translates table and selects the product attributes. diff --git a/src/Services/ContainerDeposit.php b/src/Services/ContainerDeposit.php index 4a0ae67..7b69342 100644 --- a/src/Services/ContainerDeposit.php +++ b/src/Services/ContainerDeposit.php @@ -50,7 +50,7 @@ public static function enabled(): bool /** * Deposit per unit of a product in `$currency`; the first non-negative numeric listener response wins. - * Always 0 while the feature is off. + * Always 0 while the feature is off, and for products sold by weight, length, volume or count. * * @param object $product Product model or object; `deposit` is in the product currency. * @param int $optionId Cart option id; 0 = none. @@ -58,7 +58,7 @@ public static function enabled(): bool */ public static function unit(object $product, int $optionId = 0, ?string $currency = null): float { - if (!self::enabled()) { + if (!self::enabled() || MeasuredQuantity::rules($product)->isMeasured()) { return 0.0; } $currency = $currency ?: sCommerce::currentCurrency(); @@ -109,6 +109,7 @@ public static function lineFields(float $unit, int $quantity, int $exchange = 0) * Recalculate the deposit of edited lines from their quantity and exchange. * * Returned containers stay returned: the exchange cannot drop the deposit below them. + * Measured lines never carry a deposit and are skipped. * * @param array $lines Order `products` snapshot. * @return array @@ -118,7 +119,7 @@ public static function applyToLines(array $lines): array { foreach ($lines as $index => $line) { $unit = is_array($line) ? (float)($line['deposit_unit'] ?? 0) : 0.0; - if ($unit <= 0) { + if ($unit <= 0 || isset($line['measure'])) { continue; } $quantity = max(0, (int)($line['quantity'] ?? 0)); diff --git a/src/Services/MeasuredQuantity.php b/src/Services/MeasuredQuantity.php new file mode 100644 index 0000000..3122a04 --- /dev/null +++ b/src/Services/MeasuredQuantity.php @@ -0,0 +1,942 @@ + 'g', 'length' => 'mm', 'volume' => 'ml', 'count' => 'unit']; + + /** Price bases of weight, length and volume; the basis may not exceed one largest unit. */ + public const PRICE_BASES = [1, 10, 100, 1000]; + + /** Price bases of `count`: per item, per thousand, per million. */ + public const COUNT_BASES = [1, 1000, 1000000]; + + /** Largest quantity of a product that tracks stock (`s_products.inventory` is a signed INT). */ + public const INVENTORY_MAX = 2000000000; + + /** Largest quantity of a line without stock tracking. */ + public const QUANTITY_MAX = 1000000000000; + + /** Limits of piece lines, as before measured quantities. */ + public const PIECE_MAX = 1000000; + + /** Built-in site defaults, overridden by `product.measures`. */ + public const DEFAULTS = [ + 'weight' => ['price_unit' => 'kg', 'price_per' => 1, 'step' => 1, 'min' => 1, 'max' => 1000000, 'tolerance' => 0, 'input_decimals' => 3], + 'length' => ['price_unit' => 'm', 'price_per' => 1, 'step' => 10, 'min' => 10, 'max' => 1000000, 'tolerance' => 0, 'input_decimals' => 2], + 'volume' => ['price_unit' => 'l', 'price_per' => 1, 'step' => 1, 'min' => 1, 'max' => 1000000, 'tolerance' => 0, 'input_decimals' => 3], + 'count' => ['price_unit' => 'unit', 'price_per' => 1000, 'step' => 1, 'min' => 1, 'max' => 1000000000, 'tolerance' => 0, 'input_decimals' => 0], + ]; + + /** Built-in rounding per price unit, overridden by `product.quantity_rounding`; missing units round up. */ + public const ROUNDING_DEFAULTS = [ + 'kg' => self::ROUND_NEAREST, 'g' => self::ROUND_NEAREST, + 'm' => self::ROUND_UP, 'cm' => self::ROUND_UP, 'mm' => self::ROUND_UP, + 'l' => self::ROUND_UP, 'ml' => self::ROUND_UP, + 'unit' => self::ROUND_UP, + ]; + + /** Rule keys of measured order lines, see {@see QuantityRules::lineKeys()}. */ + public const LINE_KEYS = ['measure', 'base_unit', 'price_unit', 'price_per', 'factor', 'quantity_rounding', 'quantity_step', 'quantity_min', 'quantity_tolerance']; + + /** Display keys derived from the quantity. */ + public const DISPLAY_KEYS = ['quantity_label', 'unit_price_label', 'actual_quantity_label', 'line_weight']; + + /** Actual-quantity keys of a line, set by {@see OrderActualQuantity}. */ + public const ACTUAL_KEYS = ['actual_quantity', 'actual_quantity_at', 'actual_quantity_by', 'actual_quantity_source']; + + /** Product columns that hold the quantity settings. */ + public const PRODUCT_COLUMNS = ['measure', 'price_unit', 'price_per', 'quantity_step', 'quantity_min', 'quantity_tolerance', 'quantity_rounding']; + + /** English fallbacks when a language file lacks a key. */ + private const FALLBACK = [ + 'unit_g' => 'g', 'unit_kg' => 'kg', 'unit_mm' => 'mm', 'unit_cm' => 'cm', 'unit_m' => 'm', 'unit_ml' => 'ml', 'unit_l' => 'l', 'unit_unit' => '', + 'number_decimal_separator' => '.', 'number_thousands_separator' => ',', + 'quantity_label' => ':quantity :unit', 'unit_price_label' => ':price / :basis :unit', + 'count_basis_1000' => '1,000', 'count_basis_1000000' => '1,000,000', + ]; + + /** @var array Translations resolved in this process, by `locale|key`. */ + private static array $translations = []; + + /** + * Effective rules of a product: its own columns, then the site defaults. + * + * @param object $product Product model or object with the quantity columns; missing columns mean a piece product. + * @since 1.5.0 + */ + public static function rules(object $product): QuantityRules + { + return method_exists($product, 'quantityRules') ? $product->quantityRules() : self::resolve($product); + } + + /** + * Rules from product columns and site defaults, without the product's memoized copy. + * + * @internal Use {@see self::rules()}. + */ + public static function resolve(object $product): QuantityRules + { + $measure = sProduct::MEASURE_ALIASES[(int)($product->measure ?? 0)] ?? self::PIECE; + if ($measure === self::PIECE) { + return self::piece(); + } + $defaults = self::defaults($measure); + $priceUnit = (string)($product->price_unit ?? ''); + if (self::unitFactor($measure, $priceUnit) === null) { + $priceUnit = $defaults['price_unit']; + } + $unitFactor = self::unitFactor($measure, $priceUnit); + $pricePer = self::nullableInt($product->price_per ?? null) ?? $defaults['price_per']; + if (!self::validBasis($measure, $unitFactor, $pricePer)) { + $pricePer = self::validBasis($measure, $unitFactor, $defaults['price_per']) ? $defaults['price_per'] : 1; + } + $step = max(1, self::nullableInt($product->quantity_step ?? null) ?? $defaults['step']); + $min = max(1, self::nullableInt($product->quantity_min ?? null) ?? max($defaults['min'], $step)); + $max = $defaults['max']; + if ($max > self::INVENTORY_MAX && self::tracksStock($product)) { + $max = self::INVENTORY_MAX; + } + $tolerance = self::nullableInt($product->quantity_tolerance ?? null) ?? $defaults['tolerance']; + $rounding = (string)($product->quantity_rounding ?? ''); + + return new QuantityRules($measure, self::BASE_UNITS[$measure], $priceUnit, $unitFactor, $pricePer, $unitFactor * $pricePer, + $step, $min, max($max, $min), max(0, $tolerance), $defaults['input_decimals'], + in_array($rounding, self::ROUNDINGS, true) ? $rounding : self::rounding($priceUnit)); + } + + /** + * Rules frozen in an order line at checkout; the current product is not consulted. + * + * Lines without measured keys are piece lines. + * + * @param array $line Order-line snapshot. + * @since 1.5.0 + */ + public static function fromLine(array $line): QuantityRules + { + $measure = is_string($line['measure'] ?? null) ? $line['measure'] : self::PIECE; + if (!isset(self::BASE_UNITS[$measure])) { + return self::piece(); + } + $priceUnit = is_string($line['price_unit'] ?? null) ? $line['price_unit'] : ''; + $unitFactor = self::unitFactor($measure, $priceUnit); + $factor = self::nullableInt($line['factor'] ?? null); + if ($unitFactor === null) { + $priceUnit = self::BASE_UNITS[$measure]; + $unitFactor = 1; + } + $pricePer = self::nullableInt($line['price_per'] ?? null) ?? ($factor !== null ? max(1, intdiv($factor, $unitFactor)) : 1); + $step = max(1, self::nullableInt($line['quantity_step'] ?? null) ?? 1); + $defaults = self::defaults($measure); + $rounding = (string)($line['quantity_rounding'] ?? ''); + + return new QuantityRules($measure, self::BASE_UNITS[$measure], $priceUnit, $unitFactor, $pricePer, $factor ?? $unitFactor * $pricePer, + $step, max(1, self::nullableInt($line['quantity_min'] ?? null) ?? $step), + OrderLineIdentity::tracksInventory($line) ? self::INVENTORY_MAX : self::QUANTITY_MAX, + max(0, self::nullableInt($line['quantity_tolerance'] ?? null) ?? 0), $defaults['input_decimals'], + in_array($rounding, self::ROUNDINGS, true) ? $rounding : self::ROUND_UP); + } + + /** + * Rules of piece products, matching the limits that applied before measured quantities. + * + * @since 1.5.0 + */ + public static function piece(): QuantityRules + { + return new QuantityRules(self::PIECE, '', '', 1, 1, 1, 1, 1, self::PIECE_MAX, 0, 0, self::ROUND_UP); + } + + /** + * Fit a quantity to the rules. + * + * `MODE_CLAMP` (storefront) raises to the minimum, rounds to the step with the rule's rounding, + * caps at the maximum and at `$available` rounded down to a step. The result may fall below + * the minimum only when stock is short. `MODE_STRICT` (manager, repeat, API, ERP) returns + * valid quantities unchanged and refuses others. + * + * @param int|null $available Available stock in base units; null = not capped. + * @throws MeasuredQuantityException In strict mode, when the quantity breaks a rule. + * @since 1.5.0 + */ + public static function normalize(int $quantity, QuantityRules $rules, string $mode = self::MODE_CLAMP, ?int $available = null): int + { + $step = $rules->step; + if ($mode === self::MODE_STRICT) { + if ($quantity < 1) { + throw new MeasuredQuantityException('measured_quantity_invalid'); + } + if ($quantity < $rules->min) { + throw new MeasuredQuantityException('measured_quantity_below_min', ['min' => self::label($rules->min, $rules)]); + } + if ($quantity % $step !== 0) { + throw new MeasuredQuantityException('measured_quantity_step', ['step' => self::label($step, $rules)]); + } + if ($quantity > $rules->max) { + throw new MeasuredQuantityException('measured_quantity_above_max', ['max' => self::label($rules->max, $rules)]); + } + return $quantity; + } + + $quantity = max($quantity, $rules->min); + $quantity = match ($rules->rounding) { + self::ROUND_DOWN => intdiv($quantity, $step) * $step, + // Ties round up: 1.05 kg in 100 g steps is 1.1 kg. + self::ROUND_NEAREST => intdiv(2 * $quantity + $step, 2 * $step) * $step, + default => intdiv($quantity + $step - 1, $step) * $step, + }; + $lowest = intdiv($rules->min + $step - 1, $step) * $step; + $quantity = max($quantity, $lowest); + if ($quantity > $rules->max) { + $quantity = max(intdiv($rules->max, $step) * $step, $lowest); + } + if ($available !== null) { + $quantity = min($quantity, intdiv(max(0, $available), $step) * $step); + } + + return $quantity; + } + + /** + * Parse an amount in the price unit (`"2.53"` metres) into base units (2530 mm). + * + * Accepts digits with an optional `.` fraction of at most as many significant decimals as + * the price unit has base units (3 for kg, m and l). No exponent, sign or locale separators. + * + * @throws MeasuredQuantityException When the input is not such an amount. + * @since 1.5.0 + */ + public static function parseAmount(string $input, QuantityRules $rules): int + { + if (!preg_match('/^(\d{1,15})(?:\.(\d{1,15}))?$/D', trim($input), $match)) { + throw new MeasuredQuantityException('measured_quantity_invalid'); + } + $decimals = self::decimals($rules->unitFactor); + $fraction = rtrim($match[2] ?? '', '0'); + if (strlen($fraction) > $decimals) { + throw new MeasuredQuantityException('measured_quantity_precision', ['decimals' => $decimals]); + } + + return (int)$match[1] * $rules->unitFactor + (int)str_pad($fraction, $decimals, '0'); + } + + /** + * Line total in minor units, rounded half up once: `price × quantity ÷ factor`. + * + * @param int $unitPriceScaled Price in 1e-5 currency units, see {@see self::scalePrice()}. + * @param int $quantity Base units. + * @param int $factor Base units the price refers to. + * @throws \InvalidArgumentException On negative input, or when the result does not fit an integer. + * @since 1.5.0 + */ + public static function lineTotalMinor(int $unitPriceScaled, int $quantity, int $factor): int + { + if ($unitPriceScaled < 0 || $quantity < 0 || $factor < 1) { + throw new \InvalidArgumentException('Line totals need a non-negative price and quantity and a positive factor.'); + } + if ($unitPriceScaled === 0 || $quantity === 0) { + return 0; + } + // 1e-5 units to minor units (1e-2) divide by 1000 on top of the factor. + $divisor = $factor * 1000; + if ($unitPriceScaled <= intdiv(intdiv(PHP_INT_MAX - $divisor, 2), $quantity)) { + return intdiv(2 * $unitPriceScaled * $quantity + $divisor, 2 * $divisor); + } + if (!extension_loaded('bcmath')) { + throw new \InvalidArgumentException('The line total is too large; install ext-bcmath for such values.'); + } + $total = bcdiv(bcadd(bcmul(bcmul('2', (string)$unitPriceScaled), (string)$quantity), (string)$divisor), bcmul('2', (string)$divisor), 0); + if (bccomp($total, (string)PHP_INT_MAX) > 0) { + throw new \InvalidArgumentException('The line total is too large.'); + } + + return (int)$total; + } + + /** + * Price as an integer of 1e-5 units, the scale of the `s_products` price columns. Never rounded to cents. + * + * @throws \InvalidArgumentException On a negative or non-numeric price. + * @since 1.5.0 + */ + public static function scalePrice(float|int|string $price): int + { + if (is_string($price) && preg_match('/^\s*(\d{1,13})(?:\.(\d*))?\s*$/D', $price, $match)) { + $fraction = str_pad(substr($match[2] ?? '', 0, 6), 6, '0'); + return (int)$match[1] * 100000 + intdiv((int)$fraction, 10) + ((int)$fraction[5] >= 5 ? 1 : 0); + } + if (!is_numeric($price) || !is_finite((float)$price) || (float)$price < 0) { + throw new \InvalidArgumentException('Prices must be non-negative numbers.'); + } + + return (int)round((float)$price * 100000); + } + + /** + * Exact decimal string of a quantity in units of `$factor` base units: (2530, 1000) → `2.530`. + * + * @param int $factor Power of ten. + * @since 1.5.0 + */ + public static function toPriceUnits(int $quantity, int $factor): string + { + $decimals = self::decimals($factor); + if ($decimals === 0) { + return (string)$quantity; + } + $digits = str_pad((string)abs($quantity), $decimals + 1, '0', STR_PAD_LEFT); + + return ($quantity < 0 ? '-' : '') . substr($digits, 0, -$decimals) . '.' . substr($digits, -$decimals); + } + + /** + * Express a line for accounting: weight, length and volume in the largest unit (kg, m, l) + * with the price per that unit; `count` in price-basis units with the price unchanged. + * Both are exact because every factor is a power of ten that divides the conversion. + * + * @param int $quantity Base units, normally {@see self::effective()}. + * @param float|int|string $price Price per price basis. + * @return array{quantity: string, unit: string, basis: int, unit_price: string} + * @since 1.5.0 + */ + public static function forAccounting(int $quantity, float|int|string $price, QuantityRules $rules): array + { + $scaled = self::scalePrice($price); + if ($rules->measure === self::PIECE) { + return ['quantity' => (string)$quantity, 'unit' => '', 'basis' => 1, 'unit_price' => self::decimalPrice($scaled)]; + } + if ($rules->measure === 'count') { + return ['quantity' => self::toPriceUnits($quantity, $rules->pricePer), 'unit' => 'unit', 'basis' => $rules->pricePer, 'unit_price' => self::decimalPrice($scaled)]; + } + [$unit, $largest] = self::units($rules->measure)[0]; + + return ['quantity' => self::toPriceUnits($quantity, $largest), 'unit' => $unit, 'basis' => 1, + 'unit_price' => self::decimalPrice($scaled * intdiv($largest, $rules->factor))]; + } + + /** + * Quantity for people: `2.53 m` (`2,53 м` in Ukrainian), `3 l`, `125,000` for count, the number for + * pieces. The decimals are exact; trailing zeros are dropped. + * + * @param string|null $locale Language; the current one when null. + * @since 1.5.0 + */ + public static function label(int $quantity, QuantityRules $rules, ?string $locale = null): string + { + if ($rules->measure === self::PIECE) { + return (string)$quantity; + } + if ($rules->measure === 'count') { + return self::formatNumber((string)$quantity, 0, $locale); + } + + return self::formatAmount($quantity, $rules->unitFactor, $rules->priceUnit, 0, $locale); + } + + /** + * Price per price basis for people: `€1.20 / m`, `€2.49 / 100 g`, `€2.50 / 1,000`. + * + * @param string $price Formatted price, e.g. from `sCommerce::convertPrice()`. + * @param string|null $locale Language; the current one when null. + * @since 1.5.0 + */ + public static function unitPriceLabel(string $price, QuantityRules $rules, ?string $locale = null): string + { + if ($rules->measure === self::PIECE) { + return $price; + } + if ($rules->measure === 'count') { + $basis = $rules->pricePer > 1 ? self::translate('count_basis_' . $rules->pricePer, [], $locale) : ''; + $unit = ''; + } else { + $basis = $rules->pricePer > 1 ? self::formatNumber((string)$rules->pricePer, 0, $locale) : ''; + $unit = self::translate('unit_' . $rules->priceUnit, [], $locale); + } + if ($basis === '' && $unit === '') { + return $price; + } + $label = self::translate('unit_price_label', ['price' => $price, 'basis' => $basis, 'unit' => $unit], $locale); + + return trim(preg_replace('/ {2,}/u', ' ', $label)); + } + + /** + * Billable quantity of a stored line: the actual quantity when set, else the ordered one. + * + * @param array $line + * @since 1.5.0 + */ + public static function effective(array $line): int + { + $actual = $line['actual_quantity'] ?? null; + if (isset($line['measure']) && (is_int($actual) || (is_string($actual) && ctype_digit($actual))) && (int)$actual > 0) { + return (int)$actual; + } + + return (int)($line['quantity'] ?? 0); + } + + /** + * Weight of a whole measured line in grams. + * + * Physical attributes of a measured product describe one price unit (a cable's grams per + * metre). Weight products weigh their quantity. Piece lines and count lines return null, + * as do lines without a product weight. + * + * @param array $line Line with rule keys and the per-unit `weight`. + * @since 1.5.0 + */ + public static function lineWeight(array $line): ?int + { + $rules = self::fromLine($line); + $quantity = self::effective($line); + if ($rules->measure === 'weight') { + return $quantity; + } + $weight = (int)($line['weight'] ?? 0); + if (!in_array($rules->measure, ['length', 'volume'], true) || $weight <= 0) { + return null; + } + + return intdiv(2 * $weight * $quantity + $rules->unitFactor, 2 * $rules->unitFactor); + } + + /** + * Total of a stored line from its numeric price. + * + * Piece lines keep the historical `price × quantity`. Measured lines use the exact helper + * on the effective quantity and prefer the unrounded `priceNumber`/`priceAsFloat`. + * + * @param array $line Order-line snapshot. + * @param float $price Numeric price the caller resolved for the line. + * @since 1.5.0 + */ + public static function lineSum(array $line, float $price): float + { + if (!isset($line['measure']) || !isset(self::BASE_UNITS[$line['measure']])) { + return $price * intval($line['quantity'] ?? 1); + } + foreach (['priceNumber', 'priceAsFloat'] as $key) { + if (isset($line[$key]) && is_numeric($line[$key])) { + $price = (float)$line[$key]; + break; + } + } + + return self::lineTotalMinor(self::scalePrice(max(0.0, $price)), max(0, self::effective($line)), self::fromLine($line)->factor) / 100; + } + + /** + * Quantity-dependent display keys of a new measured line. + * + * @param string $price Formatted price for the unit price label. + * @param int $weight Product weight per price unit in grams; 0 = none. + * @param string|null $locale Order language. + * @return array + * @since 1.5.0 + */ + public static function lineFields(QuantityRules $rules, int $quantity, string $price, int $weight = 0, ?string $locale = null): array + { + if (!$rules->isMeasured()) { + return []; + } + $line = $rules->lineKeys() + ['quantity' => $quantity, 'weight' => $weight]; + $fields = ['quantity_label' => self::label($quantity, $rules, $locale), 'unit_price_label' => self::unitPriceLabel($price, $rules, $locale)]; + if (($lineWeight = self::lineWeight($line)) !== null) { + $fields['line_weight'] = $lineWeight; + } + + return $fields; + } + + /** + * Refresh the quantity labels and weight of a stored measured line after a quantity change. + * + * @param array $line + * @param string|null $locale Order language. + * @return array + * @since 1.5.0 + */ + public static function refreshLine(array $line, ?string $locale = null): array + { + $rules = self::fromLine($line); + if (!$rules->isMeasured()) { + return $line; + } + $line['quantity_label'] = self::label((int)($line['quantity'] ?? 0), $rules, $locale); + if (isset($line['actual_quantity'])) { + $line['actual_quantity_label'] = self::label(self::effective($line), $rules, $locale); + } else { + unset($line['actual_quantity_label']); + } + $weight = self::lineWeight($line); + if ($weight !== null) { + $line['line_weight'] = $weight; + } else { + unset($line['line_weight']); + } + + return $line; + } + + /** + * Keep the measured keys of edited order lines server-side. + * + * Every client-provided rule, actual-quantity and display key is dropped. A line whose + * `line_key` matches a stored line takes the stored keys; other lines with a `line_key` take + * the current product's rules. Lines without `line_key` get them from + * {@see OrderLineIdentity::fillMissing()}. + * + * @param array $stored Stored order lines. + * @param array $edited Lines posted by the manager form. + * @return array + * @since 1.5.0 + */ + public static function restoreStoredRules(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']] = $line; + } + } + $server = array_merge(self::LINE_KEYS, self::ACTUAL_KEYS, self::DISPLAY_KEYS); + foreach ($edited as $index => $line) { + if (!is_array($line)) { + continue; + } + $line = array_diff_key($line, array_flip($server)); + $key = is_string($line['line_key'] ?? null) && $line['line_key'] !== '' ? $line['line_key'] : null; + if ($key !== null && isset($byKey[$key])) { + $line += array_intersect_key($byKey[$key], array_flip(array_merge(self::LINE_KEYS, self::ACTUAL_KEYS, ['unit_price_label']))); + } elseif ($key !== null) { + $productId = (int)($line['product_id'] ?? $line['id'] ?? 0); + $product = $productId > 0 ? sCommerce::getProduct($productId) : null; + if ($product && (int)($product->id ?? 0) > 0) { + $line += self::rules($product)->lineKeys(); + } + } + $edited[$index] = $line; + } + + return $edited; + } + + /** + * Site defaults of a measure: `product.measures` when valid, else the built-in values. + * + * @return array{price_unit: string, price_per: int, step: int, min: int, max: int, tolerance: int, input_decimals: int} + * @since 1.5.0 + */ + public static function defaults(string $measure): array + { + $builtIn = self::DEFAULTS[$measure] ?? self::DEFAULTS['weight']; + $configured = self::setting('product.measures', []); + $configured = is_array($configured) && is_array($configured[$measure] ?? null) ? $configured[$measure] : []; + $values = $builtIn; + foreach ($builtIn as $key => $default) { + if (array_key_exists($key, $configured) && $configured[$key] !== '' && $configured[$key] !== null) { + $values[$key] = is_int($default) ? (int)$configured[$key] : (string)$configured[$key]; + } + } + + return self::validateDefaults($measure, $values, false) === [] ? $values : $builtIn; + } + + /** + * Rounding of a price unit: `product.quantity_rounding`, then the built-in map, then `up`. + * + * @since 1.5.0 + */ + public static function rounding(string $priceUnit): string + { + $map = self::setting('product.quantity_rounding', []); + $value = is_array($map) ? ($map[$priceUnit] ?? null) : null; + if (is_string($value) && in_array($value, self::ROUNDINGS, true)) { + return $value; + } + + return self::ROUNDING_DEFAULTS[$priceUnit] ?? self::ROUND_UP; + } + + /** + * Units of a measure, largest first: `[code, base units per unit]`. + * + * @return list + * @since 1.5.0 + */ + public static function units(string $measure): array + { + return $measure === 'count' ? [['unit', 1]] : sProduct::physicalUnits($measure); + } + + /** + * Base units per price unit, or null when the unit does not belong to the measure. + * + * @since 1.5.0 + */ + public static function unitFactor(string $measure, string $unit): ?int + { + foreach (self::units($measure) as [$code, $factor]) { + if ($code === $unit) { + return $factor; + } + } + + return null; + } + + /** + * Whether a price basis is allowed: weight, length and volume up to one largest unit + * (per 100 g, per 10 cm, per 1 kg; not per 10 kg); count per 1, 1,000 or 1,000,000. + * + * @since 1.5.0 + */ + public static function validBasis(string $measure, ?int $unitFactor, int $pricePer): bool + { + if ($unitFactor === null) { + return false; + } + if ($measure === 'count') { + return in_array($pricePer, self::COUNT_BASES, true); + } + + return in_array($pricePer, self::PRICE_BASES, true) && $unitFactor * $pricePer <= 1000; + } + + /** + * Problems of a measure's site defaults, as translation keys; empty when valid. + * + * @param array $values Keys of {@see self::DEFAULTS}. + * @param bool $tracksStock Whether products of the measure track stock (signed INT limit). + * @return list + * @since 1.5.0 + */ + public static function validateDefaults(string $measure, array $values, bool $tracksStock = true): array + { + if (!isset(self::DEFAULTS[$measure])) { + return ['measured_settings_measure']; + } + $int = static fn (string $key): ?int => isset($values[$key]) && filter_var($values[$key], FILTER_VALIDATE_INT) !== false ? (int)$values[$key] : null; + $errors = []; + $unitFactor = self::unitFactor($measure, (string)($values['price_unit'] ?? '')); + if ($unitFactor === null) { + $errors[] = 'measured_settings_price_unit'; + } elseif (($pricePer = $int('price_per')) === null || !self::validBasis($measure, $unitFactor, $pricePer)) { + $errors[] = 'measured_settings_price_per'; + } + [$step, $min, $max] = [$int('step'), $int('min'), $int('max')]; + if ($step === null || $step < 1) { + $errors[] = 'measured_settings_step'; + } elseif ($min === null || $min < $step) { + $errors[] = 'measured_settings_min'; + } elseif ($max === null || $max < $min || $max > ($tracksStock ? self::INVENTORY_MAX : self::QUANTITY_MAX)) { + $errors[] = 'measured_settings_max'; + } + if (($tolerance = $int('tolerance')) === null || $tolerance < 0 || $tolerance > 10000) { + $errors[] = 'measured_settings_tolerance'; + } + if (($decimals = $int('input_decimals')) === null || $decimals < 0 || $decimals > 6) { + $errors[] = 'measured_settings_decimals'; + } + + return $errors; + } + + /** + * Validate the measured settings of a settings form: an invalid measure or rounding keeps + * its previous value and reports a translation key. + * + * @param array $product Submitted `product` settings. + * @param array $previous Stored `product` settings. + * @param list $errors Receives translation keys of refused values. + * @return array Settings to store. + * @since 1.5.0 + */ + public static function sanitizeSettings(array $product, array $previous, array &$errors = []): array + { + if (is_array($product['measures'] ?? null)) { + $measures = []; + foreach (self::DEFAULTS as $measure => $builtIn) { + $submitted = is_array($product['measures'][$measure] ?? null) ? $product['measures'][$measure] : []; + $values = []; + foreach ($builtIn as $key => $default) { + $value = $submitted[$key] ?? $default; + $values[$key] = is_int($default) && filter_var($value, FILTER_VALIDATE_INT) !== false ? (int)$value : $value; + } + $problems = self::validateDefaults($measure, $values, $measure !== 'count'); + if ($problems === []) { + $measures[$measure] = $values; + } else { + array_push($errors, ...$problems); + $measures[$measure] = is_array($previous['measures'][$measure] ?? null) ? $previous['measures'][$measure] : $builtIn; + } + } + $product['measures'] = $measures; + } + if (is_array($product['quantity_rounding'] ?? null)) { + $rounding = []; + foreach ($product['quantity_rounding'] as $unit => $value) { + if (!isset(self::ROUNDING_DEFAULTS[$unit])) { + continue; + } + if (in_array($value, self::ROUNDINGS, true)) { + $rounding[$unit] = $value; + } else { + $errors[] = 'measured_settings_rounding'; + $rounding[$unit] = $previous['quantity_rounding'][$unit] ?? self::ROUNDING_DEFAULTS[$unit]; + } + } + $product['quantity_rounding'] = $rounding; + } + $errors = array_values(array_unique($errors)); + + return $product; + } + + /** + * Validate submitted quantity settings of a product and return its column values. + * + * Empty values mean the site default (NULL). Changing the measure of a product with stock + * or with orders is refused, because it would change the meaning of stored quantities. + * Measured products cannot have a returnable container deposit. + * + * @param array $input `measure` (id or alias) and the other {@see self::PRODUCT_COLUMNS}. + * @param object $product Stored product; `deposit` should already hold the submitted deposit. + * @return array Column values; piece products get NULL settings. + * @throws MeasuredQuantityException When a value is refused. + * @since 1.5.0 + */ + public static function productSettings(array $input, object $product): array + { + $raw = $input['measure'] ?? 0; + $measureId = is_string($raw) && ($found = array_search($raw, sProduct::MEASURE_ALIASES, true)) !== false + ? (int)$found : (in_array($raw, ['', null, self::PIECE], true) ? sProduct::MEASURE_PIECE : filter_var($raw, FILTER_VALIDATE_INT)); + if ($measureId === false || ($measureId !== sProduct::MEASURE_PIECE && !isset(sProduct::MEASURE_ALIASES[$measureId]))) { + throw new MeasuredQuantityException('measured_settings_measure'); + } + $stored = (int)(method_exists($product, 'getRawOriginal') && ($product->exists ?? false) ? $product->getRawOriginal('measure') : ($product->measure ?? 0)); + $productId = (int)($product->id ?? 0); + if ($measureId !== $stored && $productId > 0) { + $inventory = (int)(method_exists($product, 'getRawOriginal') ? $product->getRawOriginal('inventory') : ($product->inventory ?? 0)); + if ($inventory !== 0 || self::productInOrders($productId)) { + throw new MeasuredQuantityException('measured_change_refused'); + } + } + $columns = array_fill_keys(array_slice(self::PRODUCT_COLUMNS, 1), null); + $columns = ['measure' => $measureId] + $columns; + if ($measureId === sProduct::MEASURE_PIECE) { + return $columns; + } + if ((float)($product->deposit ?? 0) > 0) { + throw new MeasuredQuantityException('measured_deposit_refused'); + } + $measure = sProduct::MEASURE_ALIASES[$measureId]; + $value = static fn (string $key) => is_scalar($input[$key] ?? null) && trim((string)$input[$key]) !== '' ? trim((string)$input[$key]) : null; + $integer = static function (string $key, int $min, int $max) use ($value): ?int { + $raw = $value($key); + if ($raw === null) { + return null; + } + $number = filter_var($raw, FILTER_VALIDATE_INT, ['options' => ['min_range' => $min, 'max_range' => $max]]); + if ($number === false) { + throw new MeasuredQuantityException('measured_settings_' . str_replace('quantity_', '', $key)); + } + return $number; + }; + + $columns['price_unit'] = $value('price_unit'); + if ($columns['price_unit'] !== null && self::unitFactor($measure, $columns['price_unit']) === null) { + throw new MeasuredQuantityException('measured_settings_price_unit'); + } + $columns['price_per'] = $integer('price_per', 1, 1000000); + $unit = $columns['price_unit'] ?? self::defaults($measure)['price_unit']; + if ($columns['price_per'] !== null && !self::validBasis($measure, self::unitFactor($measure, $unit), $columns['price_per'])) { + throw new MeasuredQuantityException('measured_settings_price_per'); + } + $columns['quantity_step'] = $integer('quantity_step', 1, self::INVENTORY_MAX); + $columns['quantity_min'] = $integer('quantity_min', 1, self::INVENTORY_MAX); + $step = $columns['quantity_step'] ?? self::defaults($measure)['step']; + if ($columns['quantity_min'] !== null && $columns['quantity_min'] < $step) { + throw new MeasuredQuantityException('measured_settings_min'); + } + $columns['quantity_tolerance'] = $integer('quantity_tolerance', 0, 10000); + $rounding = $value('quantity_rounding'); + if ($rounding !== null && !in_array($rounding, self::ROUNDINGS, true)) { + throw new MeasuredQuantityException('measured_settings_rounding'); + } + $columns['quantity_rounding'] = $rounding; + + return $columns; + } + + /** + * Whether a product appears in a non-deleted order. + * + * @since 1.5.0 + */ + public static function productInOrders(int $productId): bool + { + $found = false; + sOrder::query()->select(['id', 'products'])->chunkById(500, static function ($orders) use ($productId, &$found): bool { + foreach ($orders as $order) { + foreach (is_array($order->products) ? $order->products : [] as $line) { + if (is_array($line) && (int)($line['product_id'] ?? $line['id'] ?? 0) === $productId) { + $found = true; + return false; + } + } + } + return true; + }); + + return $found; + } + + /** + * A quantity in a unit for people, e.g. `2.53 m`; trailing zeros are trimmed down to `$minDecimals`. + * + * @param int $factor Base units per unit, a power of ten. + * @since 1.5.0 + */ + public static function formatAmount(int $quantity, int $factor, string $unit, int $minDecimals = 0, ?string $locale = null): string + { + $number = self::formatNumber(self::toPriceUnits($quantity, $factor), $minDecimals, $locale); + $symbol = self::translate('unit_' . $unit, [], $locale); + + return $symbol === '' ? $number : self::translate('quantity_label', ['quantity' => $number, 'unit' => $symbol], $locale); + } + + /** + * Translated package string with an English fallback. + * + * @param array $replace + * @since 1.5.0 + */ + public static function translate(string $key, array $replace = [], ?string $locale = null): string + { + $locale ??= self::currentLocale(); + $cacheKey = $locale . '|' . $key; + $text = $replace === [] ? (self::$translations[$cacheKey] ?? null) : null; + if ($text === null) { + $full = 'sCommerce::global.' . $key; + try { + $text = __($full, $replace, $locale); + if (!is_string($text) || $text === $full) { + $text = __($full, $replace, 'en'); + } + } catch (\Throwable) { + $text = $full; + } + if (!is_string($text) || $text === $full) { + $text = self::FALLBACK[$key] ?? $key; + foreach ($replace as $name => $value) { + $text = str_replace(':' . $name, (string)$value, $text); + } + } + if ($replace === []) { + self::$translations[$cacheKey] = $text; + } + } + + return $text; + } + + /** + * @internal Clear cached translations, e.g. after the language files change in tests. + */ + public static function flushTranslations(): void + { + self::$translations = []; + } + + private static function currentLocale(): string + { + try { + return (string)app('translator')->getLocale(); + } catch (\Throwable) { + return 'en'; + } + } + + /** + * Format an exact decimal string with the language's separators, trimming trailing zeros down to `$minDecimals`. + */ + private static function formatNumber(string $decimal, int $minDecimals, ?string $locale): string + { + $negative = str_starts_with($decimal, '-'); + [$integer, $fraction] = array_pad(explode('.', ltrim($decimal, '-'), 2), 2, ''); + $keep = max($minDecimals, strlen(rtrim($fraction, '0'))); + $fraction = substr(str_pad($fraction, $keep, '0'), 0, $keep); + $thousands = self::translate('number_thousands_separator', [], $locale); + $grouped = strrev(implode(strrev($thousands), str_split(strrev($integer), 3))); + + return ($negative ? '-' : '') . $grouped . ($fraction !== '' ? self::translate('number_decimal_separator', [], $locale) . $fraction : ''); + } + + /** + * Price of 1e-5 units as a decimal string with at least two decimals. + */ + private static function decimalPrice(int $scaled): string + { + $decimal = self::toPriceUnits($scaled, 100000); + [$integer, $fraction] = explode('.', $decimal); + $fraction = rtrim($fraction, '0'); + + return $integer . '.' . str_pad($fraction, 2, '0'); + } + + /** + * Number of decimals of a power of ten. + */ + private static function decimals(int $factor): int + { + return max(0, strlen((string)$factor) - 1); + } + + private static function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || filter_var($value, FILTER_VALIDATE_INT) === false) { + return null; + } + + return (int)$value; + } + + private static function tracksStock(object $product): bool + { + return !method_exists($product, 'fulfilment') || (bool)($product->fulfilment()['inventory'] ?? true); + } + + private static function setting(string $key, mixed $default): mixed + { + try { + return sCommerce::config($key, $default); + } catch (\Throwable) { + return $default; + } + } +} diff --git a/src/Services/MeasuredQuantityException.php b/src/Services/MeasuredQuantityException.php new file mode 100644 index 0000000..15aff1b --- /dev/null +++ b/src/Services/MeasuredQuantityException.php @@ -0,0 +1,32 @@ + $replace Translation placeholders. + * @param int $status HTTP status for manager and API endpoints. + */ + public function __construct(public readonly string $messageKey, public readonly array $replace = [], public readonly int $status = 422) + { + parent::__construct($messageKey, $status); + } + + /** + * Translated message in the current or the given language. + * + * @since 1.5.0 + */ + public function translated(?string $locale = null): string + { + $key = 'sCommerce::global.' . $this->messageKey; + $text = __($key, $this->replace, $locale); + + return is_string($text) && $text !== $key ? $text : $this->messageKey; + } +} diff --git a/src/Services/OrderActualQuantity.php b/src/Services/OrderActualQuantity.php new file mode 100644 index 0000000..971e9c3 --- /dev/null +++ b/src/Services/OrderActualQuantity.php @@ -0,0 +1,157 @@ + 191) { + throw new \InvalidArgumentException('Actual quantities need a known source and an operation key of 1-191 characters.'); + } + $orderId = $order instanceof sOrder ? (int)$order->getKey() : $order; + if (!(new sOrder())->hasOptionalColumn('meta')) { + throw new MeasuredQuantityException('actual_quantity_unavailable', [], 409); + } + + return EventScope::run($source, $actorId, fn (): sOrder => DB::transaction(function () use ($orderId, $lineKey, $actual, $source, $actorId, $operationKey, $overTolerance): sOrder { + /** @var sOrder|null $locked */ + $locked = sOrder::withTrashed()->whereKey($orderId)->lockForUpdate()->first(); + if (!$locked) { + throw new MeasuredQuantityException('order_not_found', [], 404); + } + $meta = is_array($locked->meta) ? $locked->meta : []; + $operations = is_array($meta[self::META_KEY] ?? null) ? $meta[self::META_KEY] : []; + if (isset($operations[$operationKey])) { + $done = $operations[$operationKey]; + if (($done['line_key'] ?? null) === $lineKey && (int)($done['actual'] ?? 0) === $actual) { + return $locked; + } + throw new MeasuredQuantityException('actual_quantity_conflict', [], 409); + } + if ($locked->trashed() || (int)$locked->status === sOrder::ORDER_STATUS_CANCELED) { + throw new MeasuredQuantityException('actual_quantity_order_closed', [], 409); + } + $lines = is_array($locked->products) ? $locked->products : []; + $index = null; + foreach ($lines as $position => $line) { + if (is_array($line) && ($line['line_key'] ?? null) === $lineKey) { + $index = $position; + break; + } + } + if ($index === null) { + throw new MeasuredQuantityException('actual_quantity_line'); + } + $line = $lines[$index]; + $rules = MeasuredQuantity::fromLine($line); + if (!$rules->isMeasured()) { + throw new MeasuredQuantityException('actual_quantity_piece'); + } + if ($actual < 1) { + throw new MeasuredQuantityException('measured_quantity_invalid'); + } + if ($actual > $rules->max) { + throw new MeasuredQuantityException('measured_quantity_above_max', ['max' => MeasuredQuantity::label($rules->max, $rules)]); + } + $ordered = (int)($line['quantity'] ?? 0); + $limit = intdiv($ordered * (10000 + $rules->tolerance), 10000); + if (!$overTolerance && $actual > $limit) { + throw new MeasuredQuantityException('actual_quantity_tolerance_exceeded'); + } + if ($this->shipped($locked, (int)$index, $lineKey)) { + throw new MeasuredQuantityException('actual_quantity_shipped', [], 409); + } + + $previous = isset($line['actual_quantity']) ? (int)$line['actual_quantity'] : null; + $line['actual_quantity'] = $actual; + $line['actual_quantity_at'] = gmdate('Y-m-d\TH:i:s\Z'); + $line['actual_quantity_by'] = $actorId; + $line['actual_quantity_source'] = $source; + $lines[$index] = OrderLineEditor::refreshSum(MeasuredQuantity::refreshLine($line, $locked->lang ?: null), (string)$locked->currency); + $lines = OrderTaxes::applyRetailExcise($lines); + + $stored = is_array($locked->products) ? $locked->products : []; + $locked->cost = OrderCostRecalculator::recalculate($locked->cost ?? 0, $stored, $lines, (string)$locked->currency); + $locked->products = $lines; + $history = is_array($locked->history) ? $locked->history : []; + $history[] = ['actual_quantity_set' => ['line_key' => $lineKey, 'old' => $previous, 'new' => $actual, 'ordered' => $ordered, + 'source' => $source, 'over_tolerance' => $actual > $limit], + 'timestamp' => now()->toDateTimeString(), 'user_id' => $actorId]; + $locked->history = $history; + $operations[$operationKey] = ['line_key' => $lineKey, 'actual' => $actual]; + $meta[self::META_KEY] = array_slice($operations, -self::MAX_OPERATIONS, null, true); + $locked->meta = $meta; + $locked->save(); + + (new StockCommitter())->adjust($locked); + + return $locked->refresh(); + })); + } + + /** + * Whether a sent shipment (shipped, in transit or delivered) covers the line. + */ + private function shipped(sOrder $order, int $index, string $lineKey): bool + { + if (!OrderShipments::available()) { + return false; + } + $shipments = sOrderShipment::query()->where('order_id', $order->id)->whereIn('status', sOrderShipment::SENT_STATUSES)->get(); + foreach ($shipments as $shipment) { + if ($shipment->items === null) { + return true; + } + foreach (is_array($shipment->items) ? $shipment->items : [] as $item) { + $key = is_array($item) ? ($item['line_key'] ?? null) : null; + if (is_array($item) && (is_string($key) && $key !== '' ? $key === $lineKey : (int)($item['line'] ?? -1) === $index)) { + return true; + } + } + } + + return false; + } +} diff --git a/src/Services/OrderCostRecalculator.php b/src/Services/OrderCostRecalculator.php index ff5d31d..2d5ef13 100644 --- a/src/Services/OrderCostRecalculator.php +++ b/src/Services/OrderCostRecalculator.php @@ -28,7 +28,8 @@ public static function recalculate(float|int|string $oldCost, array $oldProducts /** * Sum of price × quantity, using `priceNumber` when present, else the formatted `price`, - * plus the returnable container deposits of the lines. + * plus the returnable container deposits of the lines. Measured lines use the billable + * quantity and the exact rounding of {@see MeasuredQuantity::lineSum()}. * * @param array $products * @since 1.5.0 @@ -43,7 +44,7 @@ public static function productsCost(array $products, string $currency): float $price = isset($product['priceNumber']) && is_numeric($product['priceNumber']) ? (float)$product['priceNumber'] : (is_numeric($product['price'] ?? null) ? (float)$product['price'] : sCommerce::convertPriceNumber($product['price'] ?? 0, $currency, $currency)); - $total += $price * intval($product['quantity'] ?? 1); + $total += MeasuredQuantity::lineSum($product, $price); } return round($total + ContainerDeposit::total($products), 2); diff --git a/src/Services/OrderLineEditor.php b/src/Services/OrderLineEditor.php new file mode 100644 index 0000000..5281abf --- /dev/null +++ b/src/Services/OrderLineEditor.php @@ -0,0 +1,70 @@ + $edited Lines posted by the manager form. + * @return bool Whether the products changed. + * @throws MeasuredQuantityException When a measured quantity breaks its rules; the order is unchanged. + * @since 1.5.0 + */ + public function apply(sOrder $order, array $edited): bool + { + $stored = is_array($order->products) ? $order->products : []; + $currency = (string)$order->currency; + $lines = app(OrderLineIdentity::class)->fillMissing(MeasuredQuantity::restoreStoredRules($stored, $edited), $currency); + foreach ($lines as $index => $line) { + if (!is_array($line) || !isset($line['measure'])) { + continue; + } + $rules = MeasuredQuantity::fromLine($line); + $quantity = $line['quantity'] ?? null; + $quantity = is_int($quantity) || is_string($quantity) ? filter_var($quantity, FILTER_VALIDATE_INT) : false; + if ($quantity === false) { + throw new MeasuredQuantityException('measured_quantity_invalid'); + } + $line['quantity'] = MeasuredQuantity::normalize($quantity, $rules, MeasuredQuantity::MODE_STRICT); + $line['unit_price_label'] ??= MeasuredQuantity::unitPriceLabel(is_scalar($line['price'] ?? null) ? (string)$line['price'] : '', $rules, $order->lang ?: null); + $lines[$index] = self::refreshSum(MeasuredQuantity::refreshLine($line, $order->lang ?: null), $currency); + } + $lines = ContainerDeposit::applyToLines(OrderTaxes::applyRetailExcise($lines)); + + $order->products = $lines; + $order->cost = OrderCostRecalculator::recalculate($order->getOriginal('cost') ?? $order->cost ?? 0, $stored, $lines, $currency); + + return json_encode($stored) !== json_encode($lines); + } + + /** + * Recalculate `sumAsFloat` and `sum` of a measured line from its billable quantity. + * + * @param array $line + * @return array + * @since 1.5.0 + */ + public static function refreshSum(array $line, string $currency): array + { + $price = $line['priceNumber'] ?? $line['priceAsFloat'] ?? null; + $price = is_numeric($price) ? (float)$price : sCommerce::convertPriceNumber($line['price'] ?? 0, $currency, $currency); + $line['sumAsFloat'] = round(MeasuredQuantity::lineSum($line, $price), 3); + $line['sum'] = sCommerce::convertPrice($line['sumAsFloat'], $currency); + + return $line; + } +} diff --git a/src/Services/OrderLineIdentity.php b/src/Services/OrderLineIdentity.php index ddf5d0c..c0807ff 100644 --- a/src/Services/OrderLineIdentity.php +++ b/src/Services/OrderLineIdentity.php @@ -61,8 +61,9 @@ public function resolve(object $product, int $optionId = 0): array } /** - * Identity plus the fulfilment flags `requires_shipping` and `tracks_inventory`, and - * `retail_excise_rate` for products with a retail excise. + * Identity plus the fulfilment flags `requires_shipping` and `tracks_inventory`, + * `retail_excise_rate` for products with a retail excise, and the rule keys of products + * sold by measure ({@see QuantityRules::lineKeys()}). * * @param object $product Product model; objects without `fulfilment()` are shipped and tracked. * @param int $optionId Cart option id; 0 = none. @@ -82,7 +83,7 @@ public function snapshot(object $product, int $optionId = 0): array $snapshot['retail_excise_rate'] = $exciseRate; } - return $snapshot; + return $snapshot + MeasuredQuantity::rules($product)->lineKeys(); } /** @@ -90,7 +91,8 @@ public function snapshot(object $product, int $optionId = 0): array * * Lines with a `line_key` keep their stored identity. Other lines get the current identity * of product `id` with their `option_id` (default 0), and its returnable container deposit in - * `$currency`; lines of unknown products stay unchanged. + * `$currency`; lines of unknown products stay unchanged. Measured lines also get the product + * weight per price unit and the rule keys of the product. * * @param array $lines Order `products` snapshot. * @param string|null $currency Order currency; the current currency by default. @@ -111,6 +113,9 @@ public function fillMissing(array $lines, ?string $currency = null): array $optionId = filter_var($line['option_id'] ?? 0, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]) ?: 0; // Existing editable values (title, price, quantity) win; identity keys are always server-side. $lines[$index] = array_merge($line, $this->snapshot($product, $optionId)); + if (isset($lines[$index]['measure']) && !isset($line['weight']) && method_exists($product, 'physicalPayload')) { + $lines[$index]['weight'] = (int)($product->physicalPayload()['weight'] ?? 0); + } if (!isset($line['deposit_unit'])) { $lines[$index] += ContainerDeposit::lineFields(ContainerDeposit::unit($product, $optionId, $currency), max(0, (int)($line['quantity'] ?? 1)), max(0, (int)($line['exchange_quantity'] ?? 0))); diff --git a/src/Services/OrderRepeater.php b/src/Services/OrderRepeater.php index beb0cda..c2982f9 100644 --- a/src/Services/OrderRepeater.php +++ b/src/Services/OrderRepeater.php @@ -33,12 +33,14 @@ 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]); $product += ['title' => '', 'sku' => '', 'link' => '', 'coverSrc' => '']; $price = isset($product['priceNumber']) && is_numeric($product['priceNumber']) ? (float) $product['priceNumber'] : \Seiger\sCommerce\Facades\sCommerce::convertPriceNumber($product['price'] ?? 0, $source->currency, $source->currency); $product['price'] = $product['priceNumber'] = $price; - $cost += $price * (int) ($product['quantity'] ?? 1); + $cost += MeasuredQuantity::lineSum($product, $price); } unset($product); return (new sOrder())->forceFill([ diff --git a/src/Services/OrderShipments.php b/src/Services/OrderShipments.php index b96bc0e..2d5eff6 100644 --- a/src/Services/OrderShipments.php +++ b/src/Services/OrderShipments.php @@ -553,7 +553,8 @@ private function assertAllocation(sOrder $order, ?array $items, ?int $exceptId): } /** - * Ordered quantity of each line that requires shipping. + * Billable quantity of each line that requires shipping: the actual quantity of a measured + * line when set, else the ordered one (base units for measured lines). * * @return array */ @@ -561,7 +562,7 @@ private static function shippableQuantities(array $lines): array { $ordered = []; foreach ($lines as $index => $line) { - $quantity = is_array($line) ? self::positiveInt($line['quantity'] ?? null) : null; + $quantity = is_array($line) ? self::positiveInt(isset($line['actual_quantity']) ? MeasuredQuantity::effective($line) : ($line['quantity'] ?? null)) : null; if ($quantity !== null && is_int($index) && OrderLineIdentity::requiresShipping($line)) { $ordered[$index] = $quantity; } diff --git a/src/Services/OrderTaxes.php b/src/Services/OrderTaxes.php index 2e728b1..2437758 100644 --- a/src/Services/OrderTaxes.php +++ b/src/Services/OrderTaxes.php @@ -101,6 +101,7 @@ public static function apply(sOrder $order, string $source): void /** * Set `retail_excise_amount` (the excise included in the line total, for the receipt) on lines * with a `retail_excise_rate`. Called for new orders and after manager edits of the lines. + * Measured lines use their billable total, see {@see MeasuredQuantity::lineSum()}. * * @param array $lines Order `products` snapshot. * @return array @@ -114,7 +115,7 @@ public static function applyRetailExcise(array $lines): array continue; } $price = $line['priceNumber'] ?? $line['priceAsFloat'] ?? $line['price'] ?? 0; - $lines[$index]['retail_excise_amount'] = VatDisplay::excise(is_numeric($price) ? (float)$price * (int)($line['quantity'] ?? 1) : 0.0, $rate); + $lines[$index]['retail_excise_amount'] = VatDisplay::excise(is_numeric($price) ? MeasuredQuantity::lineSum($line, (float)$price) : 0.0, $rate); } return $lines; diff --git a/src/Services/QuantityRules.php b/src/Services/QuantityRules.php new file mode 100644 index 0000000..a348837 --- /dev/null +++ b/src/Services/QuantityRules.php @@ -0,0 +1,71 @@ +measure !== MeasuredQuantity::PIECE; + } + + /** + * Rule keys of an order-line snapshot; empty for pieces. + * + * @return array + * @since 1.5.0 + */ + public function lineKeys(): array + { + if (!$this->isMeasured()) { + return []; + } + + return [ + 'measure' => $this->measure, + 'base_unit' => $this->baseUnit, + 'price_unit' => $this->priceUnit, + 'price_per' => $this->pricePer, + 'factor' => $this->factor, + 'quantity_rounding' => $this->rounding, + 'quantity_step' => $this->step, + 'quantity_min' => $this->min, + 'quantity_tolerance' => $this->tolerance, + ]; + } +} diff --git a/src/Services/StockCommitter.php b/src/Services/StockCommitter.php index 5528057..3424582 100644 --- a/src/Services/StockCommitter.php +++ b/src/Services/StockCommitter.php @@ -210,6 +210,72 @@ public function release(sOrder|int $order): bool }); } + /** + * Apply line changes of a locally committed order to stock: the difference between the + * current billable quantities and the committed ones, for changed, added and removed lines. + * + * Orders that are not committed, are cancelled or deleted, or were committed by the stock + * provider are left alone; the provider learns of changes from `sCommerce.OrderUpdated`. + * A later release restores exactly the adjusted quantities. + * + * @param sOrder|int $order Order or its id. + * @return bool Whether stock changed. + * @throws StockShortageException When a product has less inventory than an increase needs; nothing changes. + * @since 1.5.0 + */ + public function adjust(sOrder|int $order): bool + { + $orderId = $order instanceof sOrder ? (int)$order->getKey() : $order; + if (!self::available()) { + return false; + } + + return EventScope::run(self::SOURCE, 0, static fn (): bool => DB::transaction(static function () use ($orderId): bool { + /** @var sOrder|null $locked */ + $locked = sOrder::query()->whereKey($orderId)->lockForUpdate()->first(); + if (!$locked || $locked->getAttribute(self::COLUMN) === null || (int)$locked->status === sOrder::ORDER_STATUS_CANCELED) { + return false; + } + $state = self::state($locked); + if (($state['committed_by'] ?? 'local') !== 'local' || !is_array($state['lines'] ?? null)) { + return false; + } + $committed = array_map('intval', $state['lines']); + $current = self::quantities($locked); + $deltas = []; + foreach (array_unique(array_merge(array_keys($committed), array_keys($current))) as $productId) { + $delta = ($current[$productId] ?? 0) - ($committed[$productId] ?? 0); + if ($delta !== 0) { + $deltas[(int)$productId] = $delta; + } + } + if ($deltas === []) { + return false; + } + $rows = StockWriter::lock(array_keys($deltas)); + foreach ($deltas as $productId => $delta) { + $row = $rows[$productId] ?? null; + if (!$row) { + // A missing product cannot be taken or returned; keep its committed quantity. + self::log('warning', 'sCommerce stock adjustment skipped a missing product', ['order_id' => $orderId, 'product_id' => $productId]); + unset($deltas[$productId], $current[$productId]); + if (isset($committed[$productId])) { + $current[$productId] = $committed[$productId]; + } + } elseif ($delta > 0 && (int)$row->inventory < $delta) { + throw new StockShortageException("Order {$orderId} needs {$delta} more of product {$productId}, {$row->inventory} in stock."); + } + } + foreach ($deltas as $productId => $delta) { + StockWriter::write($rows[$productId], (int)$rows[$productId]->inventory - $delta, null, self::STOCK_SOURCE); + } + ksort($current); + self::saveState($locked, ['lines' => $current, 'adjusted_at' => self::now()] + $state); + + return $deltas !== []; + })); + } + /** * Retry missed or failed commits and releases. * @@ -382,7 +448,8 @@ private static function canCommit(?sOrder $order): bool } /** - * Ordered quantities of lines that track inventory, by product id in id order. + * Billable quantities of lines that track inventory, by product id in id order: the actual + * quantity of a measured line when set, else the ordered one. * * @return array */ @@ -394,7 +461,7 @@ private static function quantities(sOrder $order): array continue; } $productId = (int)($line['product_id'] ?? $line['id'] ?? 0); - $quantity = (int)($line['quantity'] ?? 0); + $quantity = MeasuredQuantity::effective($line); if ($productId > 0 && $quantity > 0) { $quantities[$productId] = ($quantities[$productId] ?? 0) + $quantity; } diff --git a/src/sCommerce.php b/src/sCommerce.php index 39546a1..5e16d4d 100644 --- a/src/sCommerce.php +++ b/src/sCommerce.php @@ -24,6 +24,7 @@ use Seiger\sCommerce\Models\sProductTranslate; use Seiger\sCommerce\Payment\PaymentFlow; use Seiger\sCommerce\Payment\PaymentSchedule; +use Seiger\sCommerce\Services\MeasuredQuantity; use Seiger\sCommerce\Services\ProductListingService; use Seiger\sCommerce\Services\sPriceResolver; use Symfony\Component\HttpFoundation\Response as HttpResponse; @@ -86,7 +87,65 @@ public function __construct() 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']; + 'line-identity', 'fulfilment-modes', 'line-tax-data', 'checkout-context', 'vat-display', 'retail-excise', 'container-deposit', 'shipments', 'stock-provider', + 'measured-quantities', 'actual-quantities']; + } + + /** + * Quantity input of a product card or page. + * + * Piece products get ``. + * Products sold by measure get an input named `amount` in the price unit (metres, litres, + * kilograms) with `min`, `step` and `max` from their rules, and `data-sc-measure`, + * `data-sc-factor` (base units per price unit), `data-sc-step` and `data-sc-min` (base + * units), `data-sc-decimals` and `data-sc-unit`, which the storefront script posts as `amount`. + * `$attributes` add or replace attributes, e.g. `['data-sc-quantity' => $product->id]` in the cart. + * + * @param object $product Product model. + * @param array $attributes Extra attributes; false or null removes one. + * @return string HTML of the input element. + * @since 1.5.0 + */ + public static function quantityInput(object $product, array $attributes = []): string + { + $rules = MeasuredQuantity::rules($product); + if (!$rules->isMeasured()) { + $defaults = ['type' => 'number', 'name' => 'quantity', 'min' => 1, 'step' => 1, 'value' => 1]; + } else { + $units = static function (int $quantity) use ($rules): string { + $amount = MeasuredQuantity::toPriceUnits($quantity, $rules->unitFactor); + return str_contains($amount, '.') ? rtrim(rtrim($amount, '0'), '.') : $amount; + }; + $defaults = ['type' => 'number', 'name' => 'amount', 'inputmode' => $rules->unitFactor > 1 ? 'decimal' : 'numeric', + 'min' => $units($rules->min), 'step' => $units($rules->step), 'max' => $units($rules->max), 'value' => $units($rules->min), + 'data-sc-measure' => $rules->measure, 'data-sc-factor' => $rules->unitFactor, 'data-sc-step' => $rules->step, + 'data-sc-min' => $rules->min, 'data-sc-decimals' => min($rules->inputDecimals, strlen((string)$rules->unitFactor) - 1), + 'data-sc-unit' => MeasuredQuantity::translate('unit_' . $rules->priceUnit)]; + } + $html = ' $value) { + if ($value === null || $value === false) { + continue; + } + $html .= ' ' . e((string)$name) . ($value === true ? '' : '="' . e((string)$value) . '"'); + } + + return $html . '>'; + } + + /** + * Price of a product with its price basis in the current language: `€2.49 / 100 g`, + * `€1.20 / m`, `€2.50 / 1,000`; the plain price for piece products. + * + * @param object $product Product model; its formatted `price` is used. + * @since 1.5.0 + */ + public static function unitPrice(object $product): string + { + $price = $product->price ?? ''; + $price = is_string($price) ? $price : \Seiger\sCommerce\Facades\sCommerce::convertPrice((float)$price); + + return MeasuredQuantity::unitPriceLabel($price, MeasuredQuantity::rules($product)); } /** diff --git a/tests/standalone/README.md b/tests/standalone/README.md index 90f6318..3ac08ef 100644 --- a/tests/standalone/README.md +++ b/tests/standalone/README.md @@ -29,11 +29,14 @@ php tests/standalone/scommerce-vat-display.php php tests/standalone/scommerce-container-deposit.php 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 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 ``` `scommerce-payment-ledger.php` also runs against a disposable MySQL/MariaDB or PostgreSQL database when @@ -44,6 +47,10 @@ Package classes, Blade templates and translations are loaded from this checkout, not the installed consumer copy. These checks do not replace browser QA. The new-order checks cover draft initialization and rendering, not order creation. +`scommerce-measured-quantities.php` compares the piece cart and order JSON with +`fixtures/piece-cart-master.json`, captured from `master` by `fixtures/piece-cart-capture.php`; the +measured-quantity vectors in `fixtures/measured-quantities.json` are shared by the PHP and JS checks. + Physical attribute checks run the actual fresh installation and additive upgrade migration, verify positive legacy values are consumed and zeroed atomically on save, and exercise decimal attribute, copy and delivery flows. diff --git a/tests/standalone/fixtures/checkout-harness.php b/tests/standalone/fixtures/checkout-harness.php index 6e61fd6..e6dbdb5 100644 --- a/tests/standalone/fixtures/checkout-harness.php +++ b/tests/standalone/fixtures/checkout-harness.php @@ -40,6 +40,16 @@ final class FakeProduct public string $currency = 'EUR'; public array $attributes = []; + /** Quantity columns of products sold by measure; 0 / null = piece and site defaults. */ + public int $measure = 0; + public ?string $price_unit = null; + public ?int $price_per = null; + public ?int $quantity_step = null; + public ?int $quantity_min = null; + public ?int $quantity_tolerance = null; + public ?string $quantity_rounding = null; + public int $weight = 125; + 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 = '') { @@ -56,7 +66,7 @@ public function only(array $keys): array { return array_intersect_key(get_object */ public function physicalPayload(): array { - return ['weight' => 125, 'width' => 0, 'height' => 0, 'length' => 0, 'volume' => 0]; + return ['weight' => $this->weight, 'width' => 0, 'height' => 0, 'length' => 0, 'volume' => 0]; } /** * Provide synthetic attributes without requiring a product database. diff --git a/tests/standalone/fixtures/measured-quantities.json b/tests/standalone/fixtures/measured-quantities.json new file mode 100644 index 0000000..a582ae3 --- /dev/null +++ b/tests/standalone/fixtures/measured-quantities.json @@ -0,0 +1,52 @@ +{ + "lineTotalMinor": [ + [120000, 2525, 1000, 303], + [120000, 2530, 1000, 304], + [120000, 2533, 1000, 304], + [120000, 2540, 1000, 305], + [249000, 330, 100, 822], + [249500, 330, 100, 823], + [250000, 125000, 1000, 31250], + [250000, 123456, 1000, 30864], + [50000, 2500000, 1000000, 125], + [50000, 1234567, 1000000, 62], + [1250000, 3, 1, 3750], + [500, 1, 1, 1], + [499, 1, 1, 0], + [990000, 2400, 1000, 2376], + [0, 2530, 1000, 0], + [10000000000, 1000000000, 1000, 10000000000000] + ], + "parseAmount": [ + ["2.53", 1000, 2530], + ["2.5", 1000, 2500], + ["2", 1000, 2000], + ["0.001", 1000, 1], + ["2.530", 1000, 2530], + ["0", 1000, 0], + ["330", 1, 330], + ["12.3", 10, 123], + ["2.5301", 1000, null], + ["3.5", 1, null], + ["1e3", 1000, null], + ["2,5", 1000, null], + ["-1", 1000, null], + ["", 1000, null], + [" 2.53 ", 1000, 2530] + ], + "scalePrice": [ + ["2.495", 249500], + ["0.0025", 250], + ["1.200000", 120000], + ["0.000005", 1], + ["0.000004", 0], + ["12", 1200000], + [2.495, 249500], + [1.2, 120000] + ], + "orderSum": [ + [[{"measure": "length", "factor": 1000, "priceNumber": 1.2, "quantity": 2530}, {"price": "12.50", "quantity": 2}], 28.04], + [[{"measure": "weight", "factor": 1000, "priceAsFloat": 6, "quantity": 1040, "actual_quantity": 1037}], 6.22], + [[{"measure": "weight", "factor": 100, "priceNumber": "2.495", "quantity": 330}, {"measure": "count", "factor": 1000, "priceNumber": 2.5, "quantity": 123456}], 316.87] + ] +} diff --git a/tests/standalone/fixtures/piece-cart-capture.php b/tests/standalone/fixtures/piece-cart-capture.php new file mode 100644 index 0000000..2a26269 --- /dev/null +++ b/tests/standalone/fixtures/piece-cart-capture.php @@ -0,0 +1,26 @@ + +$root = $argv[1]; +if (!function_exists('config')) { + function config($key = null, $default = null) { return app('config')->get($key, $default); } +} +require $root . '/tests/standalone/fixtures/checkout-harness.php'; + +use Illuminate\Config\Repository; +use Seiger\sCommerce\Models\sOrder; +use Seiger\sCommerce\Models\sProduct; + +$app->instance('config', new Repository($app['config']->getAttributes())); +$shop->config['basic.deliveries_on'] = 0; +$shop->products = [ + 1 => new FakeProduct(1, 'PIECE-1', 12.5, sProduct::MODE_SIMPLE, '4006381333931'), + 2 => new FakeProduct(2, 'PIECE-2', 3.335, sProduct::MODE_SERVICE), + 3 => new FakeProduct(3, 'PIECE-3', 0.1), +]; +$cart = (new Seiger\sCommerce\Cart\sCart()); +$_SESSION['sCart'] = [1 => [0 => 3], 2 => [0 => 2, 5 => 1], 3 => [0 => 7]]; +$mini = (new Seiger\sCommerce\Cart\sCart())->getMiniCart(); +$result = $checkout($mini['items'], ['cost' => $mini['totalSum']])->processOrder(); +$order = sOrder::query()->find($result['order']->id); +echo json_encode(['cart' => $mini, 'order_products' => $order->products, 'order_cost' => (string)$order->cost], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n"; diff --git a/tests/standalone/fixtures/piece-cart-master.json b/tests/standalone/fixtures/piece-cart-master.json new file mode 100644 index 0000000..330a249 --- /dev/null +++ b/tests/standalone/fixtures/piece-cart-master.json @@ -0,0 +1,362 @@ +{ + "cart": { + "totalGoods": 53.205, + "totalGoodsFormatted": "53.21 EUR", + "totalDeposit": 0, + "totalDepositFormatted": "", + "totalSum": 53.205, + "totalSumFormatted": "53.21 EUR", + "totalSumNet": 53.21, + "totalSumNetFormatted": "53.21 EUR", + "totalSumDisplay": 53.205, + "totalSumDisplayFormatted": "53.21 EUR", + "totalRetailExcise": 0, + "totalRetailExciseFormatted": "", + "vatDisplay": "gross", + "items": [ + { + "link": "", + "coverSrc": "", + "category": 0, + "oldPrice": "", + "id": 1, + "sku": "PIECE-1", + "price": "12.50", + "inventory": 100, + "title": "Product 1", + "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, + "product_sku": "PIECE-1", + "gtin": "4006381333931", + "unit_price_net": null, + "tax_rate": null, + "requires_shipping": true, + "tracks_inventory": true, + "vatRate": 0, + "vatDisplay": "gross", + "priceGrossAsFloat": 12.5, + "priceNetAsFloat": 12.5, + "priceGross": "12.50 EUR", + "priceNet": "12.50 EUR", + "oldPriceGrossAsFloat": 0, + "oldPriceNetAsFloat": 0, + "oldPriceGross": "", + "oldPriceNet": "", + "displayPriceAsFloat": 12.5, + "displayPrice": "12.50 EUR", + "displayOldPriceAsFloat": 0, + "displayOldPrice": "", + "retailExciseRate": 0, + "retailExciseAmountAsFloat": 0, + "retailExciseAmount": "", + "quantity": 3, + "sumAsFloat": 37.5, + "sum": "37.50 EUR", + "sumNetAsFloat": 37.5, + "sumNet": "37.50 EUR", + "displaySumAsFloat": 37.5, + "displaySum": "37.50 EUR", + "sumRetailExciseAsFloat": 0, + "sumRetailExcise": "" + }, + { + "link": "", + "coverSrc": "", + "category": 0, + "oldPrice": "", + "id": 2, + "sku": "PIECE-2", + "price": "3.34", + "inventory": 100, + "title": "Product 2", + "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, + "product_sku": "PIECE-2", + "gtin": null, + "unit_price_net": null, + "tax_rate": null, + "requires_shipping": false, + "tracks_inventory": false, + "vatRate": 0, + "vatDisplay": "gross", + "priceGrossAsFloat": 3.335, + "priceNetAsFloat": 3.34, + "priceGross": "3.34 EUR", + "priceNet": "3.34 EUR", + "oldPriceGrossAsFloat": 0, + "oldPriceNetAsFloat": 0, + "oldPriceGross": "", + "oldPriceNet": "", + "displayPriceAsFloat": 3.335, + "displayPrice": "3.34 EUR", + "displayOldPriceAsFloat": 0, + "displayOldPrice": "", + "retailExciseRate": 0, + "retailExciseAmountAsFloat": 0, + "retailExciseAmount": "", + "quantity": 2, + "sumAsFloat": 6.67, + "sum": "6.67 EUR", + "sumNetAsFloat": 6.67, + "sumNet": "6.67 EUR", + "displaySumAsFloat": 6.67, + "displaySum": "6.67 EUR", + "sumRetailExciseAsFloat": 0, + "sumRetailExcise": "" + }, + { + "link": "", + "coverSrc": "", + "category": 0, + "oldPrice": "", + "id": 2, + "sku": "PIECE-2", + "price": "8.34", + "inventory": 100, + "title": "Product 2", + "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, + "product_sku": "PIECE-2", + "gtin": null, + "unit_price_net": null, + "tax_rate": null, + "requires_shipping": false, + "tracks_inventory": false, + "vatRate": 0, + "vatDisplay": "gross", + "priceGrossAsFloat": 8.335, + "priceNetAsFloat": 8.34, + "priceGross": "8.34 EUR", + "priceNet": "8.34 EUR", + "oldPriceGrossAsFloat": 0, + "oldPriceNetAsFloat": 0, + "oldPriceGross": "", + "oldPriceNet": "", + "displayPriceAsFloat": 8.335, + "displayPrice": "8.34 EUR", + "displayOldPriceAsFloat": 0, + "displayOldPrice": "", + "retailExciseRate": 0, + "retailExciseAmountAsFloat": 0, + "retailExciseAmount": "", + "quantity": 1, + "sumAsFloat": 8.335, + "sum": "8.34 EUR", + "sumNetAsFloat": 8.34, + "sumNet": "8.34 EUR", + "displaySumAsFloat": 8.335, + "displaySum": "8.34 EUR", + "sumRetailExciseAsFloat": 0, + "sumRetailExcise": "" + }, + { + "link": "", + "coverSrc": "", + "category": 0, + "oldPrice": "", + "id": 3, + "sku": "PIECE-3", + "price": "0.10", + "inventory": 100, + "title": "Product 3", + "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, + "product_sku": "PIECE-3", + "gtin": null, + "unit_price_net": null, + "tax_rate": null, + "requires_shipping": true, + "tracks_inventory": true, + "vatRate": 0, + "vatDisplay": "gross", + "priceGrossAsFloat": 0.1, + "priceNetAsFloat": 0.1, + "priceGross": "0.10 EUR", + "priceNet": "0.10 EUR", + "oldPriceGrossAsFloat": 0, + "oldPriceNetAsFloat": 0, + "oldPriceGross": "", + "oldPriceNet": "", + "displayPriceAsFloat": 0.1, + "displayPrice": "0.10 EUR", + "displayOldPriceAsFloat": 0, + "displayOldPrice": "", + "retailExciseRate": 0, + "retailExciseAmountAsFloat": 0, + "retailExciseAmount": "", + "quantity": 7, + "sumAsFloat": 0.7, + "sum": "0.70 EUR", + "sumNetAsFloat": 0.7, + "sumNet": "0.70 EUR", + "displaySumAsFloat": 0.7, + "displaySum": "0.70 EUR", + "sumRetailExciseAsFloat": 0, + "sumRetailExcise": "" + } + ] + }, + "order_products": [ + { + "link": "", + "coverSrc": "", + "category": 0, + "oldPrice": "", + "id": 1, + "sku": "PIECE-1", + "price": "12.50", + "inventory": 100, + "title": "Product 1", + "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, + "product_sku": "PIECE-1", + "gtin": "4006381333931", + "unit_price_net": null, + "tax_rate": null, + "requires_shipping": true, + "tracks_inventory": true, + "quantity": 3, + "sumAsFloat": 37.5, + "sum": "37.50 EUR" + }, + { + "link": "", + "coverSrc": "", + "category": 0, + "oldPrice": "", + "id": 2, + "sku": "PIECE-2", + "price": "3.34", + "inventory": 100, + "title": "Product 2", + "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, + "product_sku": "PIECE-2", + "gtin": null, + "unit_price_net": null, + "tax_rate": null, + "requires_shipping": false, + "tracks_inventory": false, + "quantity": 2, + "sumAsFloat": 6.67, + "sum": "6.67 EUR" + }, + { + "link": "", + "coverSrc": "", + "category": 0, + "oldPrice": "", + "id": 2, + "sku": "PIECE-2", + "price": "8.34", + "inventory": 100, + "title": "Product 2", + "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, + "product_sku": "PIECE-2", + "gtin": null, + "unit_price_net": null, + "tax_rate": null, + "requires_shipping": false, + "tracks_inventory": false, + "quantity": 1, + "sumAsFloat": 8.335, + "sum": "8.34 EUR" + }, + { + "link": "", + "coverSrc": "", + "category": 0, + "oldPrice": "", + "id": 3, + "sku": "PIECE-3", + "price": "0.10", + "inventory": 100, + "title": "Product 3", + "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, + "product_sku": "PIECE-3", + "gtin": null, + "unit_price_net": null, + "tax_rate": null, + "requires_shipping": true, + "tracks_inventory": true, + "quantity": 7, + "sumAsFloat": 0.7, + "sum": "0.70 EUR" + } + ], + "order_cost": "53.205" +} diff --git a/tests/standalone/scommerce-actual-quantities.php b/tests/standalone/scommerce-actual-quantities.php new file mode 100644 index 0000000..1c3a98f --- /dev/null +++ b/tests/standalone/scommerce-actual-quantities.php @@ -0,0 +1,228 @@ +get($key, $default); } +} +require __DIR__ . '/fixtures/checkout-harness.php'; + +use Illuminate\Config\Repository; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Seiger\sCommerce\Controllers\OrderActualQuantityController; +use Seiger\sCommerce\Interfaces\StockProviderInterface; +use Seiger\sCommerce\Models\sOrder; +use Seiger\sCommerce\Models\sProduct; +use Seiger\sCommerce\Services\MeasuredQuantity; +use Seiger\sCommerce\Services\MeasuredQuantityException; +use Seiger\sCommerce\Services\OrderActualQuantity; +use Seiger\sCommerce\Services\OrderCostRecalculator; +use Seiger\sCommerce\Services\OrderLineIdentity; +use Seiger\sCommerce\Services\OrderPaymentAction; +use Seiger\sCommerce\Services\OrderShipments; +use Seiger\sCommerce\Services\StockCommitter; +use Seiger\sCommerce\Shipping\ShipmentData; + +$app->instance('config', new Repository($app['config']->getAttributes())); +$schema->create('s_products', static function ($table): void { + $table->id(); + $table->string('sku')->default(''); + $table->smallInteger('mode')->default(0); + $table->smallInteger('availability')->default(1); + $table->integer('inventory')->default(0); + $table->timestamps(); +}); +foreach (['2026_10_27_000000_add_measured_quantities_to_products', '2026_10_20_000003_add_inventory_committed_at_to_orders', '2026_10_20_000007_create_order_shipments_table'] as $name) { + (require $package . "/database/migrations/$name.php")->up(); +} +$shop->products = [ + 10 => tap(new FakeProduct(10, 'CABLE', 1.2, title: 'Cable'), static function ($p) { $p->measure = 2; $p->price_unit = 'm'; $p->quantity_step = 10; $p->quantity_min = 1000; $p->quantity_tolerance = 500; }), + 11 => new FakeProduct(11, 'PIECE', 12.5), + 12 => tap(new FakeProduct(12, 'NAILS', 6.0, title: 'Nails'), static function ($p) { $p->measure = 1; $p->quantity_step = 100; $p->quantity_min = 100; $p->weight = 0; }), +]; +foreach ([10 => 10000, 11 => 50, 12 => 5000] as $id => $inventory) { + $db->table('s_products')->insert(['id' => $id, 'sku' => "P$id", 'inventory' => $inventory]); +} +$stock = static fn (int $id): int => (int)DB::table('s_products')->where('id', $id)->value('inventory'); +$commitMode = static function (string $mode) use ($app): void { + $app['config']->set('seiger.settings.sCommerce.product', ['inventory_on' => 2, 'inventory_commit' => $mode, 'inventory_release_on_cancel' => 1]); +}; +$lineOf = static function (int $productId, int $quantity, array $extra = []) use ($shop): array { + $product = $shop->products[$productId]; + $line = $extra + ['id' => $productId, 'title' => $product->title, 'price' => number_format($product->price, 2, '.', ''), 'priceAsFloat' => $product->price, + 'quantity' => $quantity, 'weight' => $product->weight] + app(OrderLineIdentity::class)->snapshot($product); + return $line + MeasuredQuantity::lineFields(MeasuredQuantity::rules($product), $quantity, $line['price'], $product->weight); +}; +$newOrder = static function (array $lines, array $attributes = []): sOrder { + $order = new sOrder(); + $order->forceFill($attributes + ['identifier' => bin2hex(random_bytes(8)), 'currency' => 'EUR', 'status' => 1, 'payment_status' => 0, 'lang' => 'en', + 'products' => $lines, 'cost' => OrderCostRecalculator::productsCost($lines, 'EUR') + 5]); + $order->save(); + return $order; +}; +$refused = static function (callable $call, string $key, ?int $status = null): bool { + try { + $call(); + } catch (MeasuredQuantityException $exception) { + return $exception->messageKey === $key && ($status === null || $exception->status === $status); + } + return false; +}; +$service = new OrderActualQuantity(); +$commitMode(StockCommitter::MODE_NONE); + +// 1. Within tolerance: line, excise, weight, cost and history follow; one OrderUpdated with source and actor. +$order = $newOrder([$lineOf(10, 2530, ['retail_excise_rate' => 5]), $lineOf(11, 2)]); +$check((float)$order->cost === 3.04 + 25.0 + 5.0, 'Order cost before'); +$events = []; +$updated = $service->set($order, '10:0', 2553, 'manager', 7, 'op-1'); +$line = $updated->products[0]; +$check($line['quantity'] === 2530 && $line['actual_quantity'] === 2553 && $line['actual_quantity_by'] === 7 && $line['actual_quantity_source'] === 'manager' + && preg_match('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/', $line['actual_quantity_at']) === 1, 'Actual keys; the ordered quantity is kept'); +$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); +$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')); +$check(count($orderUpdates) === 1 && $orderUpdates[0][1]['source'] === 'manager' && $orderUpdates[0][1]['actorId'] === 7, 'Exactly one OrderUpdated with source and actor'); +$check($service->set($order, '10:0', 2533, 'erp', 0, 'erp-1')->products[0]['actual_quantity'] === 2533, 'A step is not required: the actual quantity is a measurement'); + +// 2. Tolerance: 5% over 2530 mm is 2656 mm. +$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($service->set($order, '10:0', 1200, 'manager', 7, 'op-5')->products[0]['actual_quantity'] === 1200, 'Below is always allowed'); + +// 3. Idempotency by operation key. +$historyCount = count(sOrder::query()->find($order->id)->history); +$events = []; +$again = $service->set($order, '10:0', 1200, 'manager', 7, 'op-5'); +$check(count($again->history) === $historyCount && $events === [], 'The same key and value returns the order unchanged'); +$check($refused(fn () => $service->set($order, '10:0', 1300, 'manager', 7, 'op-5'), 'actual_quantity_conflict', 409), 'The same key with another value conflicts'); +// A double submit: the second request waits for the order lock and finds the key. +$first = $service->set($order->id, '10:0', 1400, 'api', 0, 'double'); +$second = $service->set($order->id, '10:0', 1400, 'api', 0, 'double'); +$check(count($second->history) === count($first->history) && $second->products[0]['actual_quantity'] === 1400, 'A double submit has one effect'); +for ($i = 0; $i < 60; $i++) { + $service->set($order, '10:0', 1500 + $i, 'erp', 0, "bulk-$i"); +} +$check(count(sOrder::query()->find($order->id)->meta[OrderActualQuantity::META_KEY]) === OrderActualQuantity::MAX_OPERATIONS, 'Operation keys are bounded'); + +// 4. Refusals. +$check($refused(fn () => $service->set($order, '11:0', 3, 'manager', 7, 'piece'), 'actual_quantity_piece'), 'Piece lines are refused'); +$check($refused(fn () => $service->set($order, '99:0', 3, 'manager', 7, 'unknown'), 'actual_quantity_line'), 'Unknown lines are refused'); +$check($refused(fn () => $service->set($order, '10:0', 0, 'manager', 7, 'zero'), 'measured_quantity_invalid'), 'Zero is refused; removing a line is a product edit'); +$cancelled = $newOrder([$lineOf(10, 2530)], ['status' => sOrder::ORDER_STATUS_CANCELED]); +$check($refused(fn () => $service->set($cancelled, '10:0', 2540, 'manager', 7, 'c'), 'actual_quantity_order_closed', 409), 'Cancelled orders are refused'); +$deleted = $newOrder([$lineOf(10, 2530)]); +$deleted->delete(); +$check($refused(fn () => $service->set($deleted->id, '10:0', 2540, 'manager', 7, 'd'), 'actual_quantity_order_closed', 409), 'Deleted orders are refused'); +$shipped = $newOrder([$lineOf(10, 2530), $lineOf(12, 1000)]); +(new OrderShipments())->upsert($shipped, new ShipmentData(status: 'shipped', trackingNumber: 'T1', items: [['line' => 0, 'quantity' => 2530]], source: 'manual', externalRef: 'S1')); +$check($refused(fn () => $service->set($shipped, '10:0', 2540, 'manager', 7, 's'), 'actual_quantity_shipped', 409) + && $service->set($shipped, '12:0', 990, 'manager', 7, 's2')->products[1]['actual_quantity'] === 990, 'Lines in a sent shipment are refused, others are not'); +$failed = false; +try { + $service->set($order, '10:0', 2540, 'storefront', 7, 'x'); +} catch (InvalidArgumentException $exception) { + $failed = !$exception instanceof MeasuredQuantityException; +} +$check($failed, 'Unknown sources are refused'); + +// 5. Stock: a local commit follows the actual quantity in both directions. +$commitMode(StockCommitter::MODE_ORDER_CREATED); +$committed = $newOrder([$lineOf(10, 2530), $lineOf(12, 1000)]); +$check($stock(10) === 7470 && $stock(12) === 4000, 'Ordered quantities committed'); +$service->set($committed, '10:0', 2553, 'manager', 7, 'stock-1'); +$service->set($committed, '12:0', 950, 'erp', 0, 'stock-2'); +$check($stock(10) === 7447 && $stock(12) === 4050, 'Stock follows actual quantities up and down'); +$committed = sOrder::query()->find($committed->id); +$committed->status = sOrder::ORDER_STATUS_CANCELED; +$committed->save(); +$check($stock(10) === 10000 && $stock(12) === 5000, 'Release restores exactly the committed total'); +final class ProviderCommits implements StockProviderInterface +{ + public function available(sProduct $product, int $modificationId = 0, array $context = []): ?int { return null; } + public function commit(sOrder $order): bool { return true; } + public function release(sOrder $order): bool { return true; } +} +$app->instance(StockProviderInterface::class, new ProviderCommits()); +$provided = $newOrder([$lineOf(10, 2530)]); +$service->set($provided, '10:0', 2600, 'manager', 7, 'provider'); +$check($stock(10) === 10000, 'Provider-committed orders make no local write'); +$app->offsetUnset(StockProviderInterface::class); +$commitMode(StockCommitter::MODE_NONE); +$service->set($newOrder([$lineOf(10, 2530)]), '10:0', 2600, 'manager', 7, 'none'); +$check($stock(10) === 10000, 'inventory_commit = none makes no write'); +$commitMode(StockCommitter::MODE_ORDER_CREATED); +DB::table('s_products')->where('id', 10)->update(['inventory' => 2540]); +$short = $newOrder([$lineOf(10, 2530)]); +$check($stock(10) === 10, 'Short stock committed'); +$shortage = false; +try { + $service->set($short, '10:0', 2600, 'manager', 7, 'short'); +} catch (Seiger\sCommerce\Services\StockShortageException) { + $shortage = !isset(sOrder::query()->find($short->id)->products[0]['actual_quantity']); +} +$check($shortage, 'A stock shortage rolls the actual quantity back'); +$commitMode(StockCommitter::MODE_NONE); + +// 6. Shipments cover the actual quantity. +$app['config']->set('seiger.settings.sCommerce.shipping', ['sync_order_status' => 1, 'partial_policy' => 'on_complete']); +$cut = $newOrder([$lineOf(10, 2530)]); +$cut = $service->set($cut, '10:0', 2553, 'manager', 7, 'cut'); +$shipments = new OrderShipments(); +$shipments->upsert($cut, new ShipmentData(status: 'delivered', trackingNumber: 'C1', items: [['line' => 0, 'quantity' => 2530]], source: 'manual', externalRef: 'C1')); +$cut = sOrder::query()->find($cut->id); +$check(!OrderShipments::coverage($cut)['fullyDelivered'] && (int)$cut->status !== sOrder::ORDER_STATUS_DELIVERED, 'The ordered quantity does not cover a longer cut'); +$shipments->upsert($cut, new ShipmentData(status: 'delivered', trackingNumber: 'C2', items: [['line' => 0, 'quantity' => 23]], source: 'manual', externalRef: 'C2')); +$cut = sOrder::query()->find($cut->id); +$check(OrderShipments::coverage($cut)['fullyDelivered'] && (int)$cut->status === sOrder::ORDER_STATUS_DELIVERED, 'Full actual coverage delivers the order (and triggers offline settlement)'); + +// 7. Authorization ceiling: cost plus the tolerance of measured lines, half up. +$ceiling = $newOrder([$lineOf(10, 2500, ['priceAsFloat' => 1.24, 'price' => '1.24']), $lineOf(11, 1)]); +$check($ceiling->authorizationCeiling() === round((float)$ceiling->cost + 0.16, 2), 'Ceiling adds 5% of 3.10 = 0.155, half up 0.16'); +$ceiling = $newOrder([$lineOf(10, 2525)]); +$check($ceiling->authorizationCeiling() === round((float)$ceiling->cost + 0.15, 2), 'Ceiling adds 5% of 3.03 = 0.1515, 0.15'); +$check($newOrder([$lineOf(12, 1000), $lineOf(11, 1)])->authorizationCeiling() === 6.0 + 12.5 + 5.0, 'Without tolerance the ceiling is the cost'); + +// 8. Manager endpoint: POST only, CSRF, nonce, permissions and validation. +$app->mgrUser = 7; +$app->permissions = ['exec_module']; +$_SESSION = ['mgrValidated' => 1, '_token' => 'csrf-token']; +$target = $newOrder([$lineOf(10, 2530)]); +$nonce = OrderPaymentAction::issueNonce((int)$target->id, 0, OrderActualQuantityController::NONCE_ACTION); +$post = static function (array $input, string $method = 'POST') use ($target, $nonce) { + $request = Request::create('/', $method, $input + ['_token' => 'csrf-token', 'order' => (string)$target->id, 'nonce' => $nonce, 'line_key' => '10:0']); + return (new OrderActualQuantityController())->handle($request); +}; +$check($post(['amount' => '2.553'], 'GET')->getStatusCode() === 405, 'GET is refused'); +$check($post(['amount' => '2.553', '_token' => 'stale'])->getStatusCode() === 403, 'CSRF is checked'); +$check($post(['amount' => '2.553', 'nonce' => 'other'])->getStatusCode() === 403, 'The nonce is checked'); +$app->permissions = []; +$check($post(['amount' => '2.553'])->getStatusCode() === 403, 'exec_module is required'); +$app->permissions = ['exec_module']; +$check($post(['amount' => '2,553'])->getStatusCode() === 422 && $post(['amount' => '2.5531'])->getStatusCode() === 422 + && $post(['amount' => '2.553', 'line_key' => '99:0'])->getStatusCode() === 422, 'Invalid input is 422'); +$check($post(['amount' => '3'])->getStatusCode() === 422, 'Above the tolerance without the flag is 422'); +$check($post(['amount' => '3', 'over_tolerance' => '1'])->getStatusCode() === 403, 'Exceeding the tolerance needs the permission'); +$response = $post(['amount' => '2.553']); +$check($response->getStatusCode() === 200 && $response->getData(true)['success'] === true + && sOrder::query()->find($target->id)->products[0]['actual_quantity'] === 2553, 'A valid amount is saved'); +$saves = count(sOrder::query()->find($target->id)->history); +$check($post(['amount' => '2.553'])->getStatusCode() === 200 && count(sOrder::query()->find($target->id)->history) === $saves && $saves === 1, 'A resubmitted form records once'); +$app->permissions = ['exec_module', OrderActualQuantityController::PERMISSION_OVER_TOLERANCE]; +$check($post(['amount' => '3', 'over_tolerance' => '1'])->getStatusCode() === 200 && sOrder::query()->find($target->id)->products[0]['actual_quantity'] === 3000, 'With the permission and the flag'); +$module = file_get_contents($package . '/module/sCommerceModule.php'); +$check(str_contains($module, 'case "orderActualQuantitySave":') && str_contains($module, 'OrderActualQuantityController::NONCE_ACTION'), 'Module routes the endpoint and issues the nonce'); +$permission = file_get_contents($package . '/database/migrations/2026_10_27_000001_add_scommerce_actual_quantity_permission.php'); +$check(str_contains($permission, "'scommerce_order_actual_over_tolerance'"), 'Permission migration'); + +echo "$checks actual quantity checks passed (isolated SQLite; no site database)\n"; diff --git a/tests/standalone/scommerce-bulk-menu.php b/tests/standalone/scommerce-bulk-menu.php index c143a1e..3a0adc9 100644 --- a/tests/standalone/scommerce-bulk-menu.php +++ b/tests/standalone/scommerce-bulk-menu.php @@ -90,6 +90,7 @@ public function error($message, $context = []) { $db->table('s_orders')->where('id',1)->update(['user_info'=>'{"first_name":"","phone":"+380981111111"}', 'products'=>json_encode([['title'=>'','sku'=>'SKU-1','quantity'=>2,'price'=>25]]), 'created_at'=>'2026-08-31 07:05:00']); $files->makeDirectory($cache); $db->table('s_orders')->where('id',1)->update(['delivery_info'=>json_encode(['method'=>'nova_post', 'cost'=>0, 'nova_post'=>['city'=>'Харків', 'warehouse'=>'Відділення № 2', 'city_ref'=>'private-city-uuid', 'warehouse_ref'=>'private-warehouse-uuid', 'ttn'=>'000123456789']])]); +$db->table('s_orders')->where('id',3)->update(['products'=>json_encode([['title'=>'Cable','sku'=>'CABLE','quantity'=>2530,'price'=>1.2,'measure'=>'length','base_unit'=>'mm','price_unit'=>'m','price_per'=>1,'factor'=>1000,'quantity_step'=>10]])]); try { $response = $call(['ids'=>[3,1], 'action'=>'print']); $check($response->getStatusCode() === 200, 'Actual Blade print document renders'); @@ -99,6 +100,7 @@ public function error($message, $context = []) { $check(!str_contains($html,' diff --git a/views/partials/settingsProductConfiguration.blade.php b/views/partials/settingsProductConfiguration.blade.php index b7fa9ca..ffe4348 100644 --- a/views/partials/settingsProductConfiguration.blade.php +++ b/views/partials/settingsProductConfiguration.blade.php @@ -201,6 +201,77 @@ +@php($measuredUnits = fn (string $measure) => \Seiger\sCommerce\Services\MeasuredQuantity::units($measure)) +
+
+ + +
+
+ + + + + + + + + + + + + + + @foreach(\Seiger\sCommerce\Services\MeasuredQuantity::DEFAULTS as $measure => $builtIn) + @php($values = \Seiger\sCommerce\Services\MeasuredQuantity::defaults($measure)) + @php($name = 'product__measures[' . $measure . ']') + + + + + @foreach(['step', 'min', 'max', 'tolerance', 'input_decimals'] as $key) + + @endforeach + + @endforeach + +
@lang('sCommerce::global.measure')@lang('sCommerce::global.price_unit')@lang('sCommerce::global.price_per')@lang('sCommerce::global.quantity_step')@lang('sCommerce::global.quantity_min')@lang('sCommerce::global.quantity_max')@lang('sCommerce::global.quantity_tolerance'), ‱@lang('sCommerce::global.quantity_input_decimals')
@lang('sCommerce::global.measure_' . $measure), {{\Seiger\sCommerce\Services\MeasuredQuantity::translate('unit_' . \Seiger\sCommerce\Services\MeasuredQuantity::BASE_UNITS[$measure]) ?: '1'}} + + + +
+
+
+
+
+ + +
+
+ @foreach(\Seiger\sCommerce\Services\MeasuredQuantity::DEFAULTS as $measure => $builtIn) +
+ @lang('sCommerce::global.measure_' . $measure): + @foreach($measuredUnits($measure) as [$unit, $factor]) + + @endforeach +
+ @endforeach +
+