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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions config/sCommerceSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
* Products sold by weight, length, volume or count. Quantities stay integers in base units
* (grams, millimetres, millilitres or items); NULL settings fall back to the site defaults.
*
* @since 1.5.0
*/
return new class extends Migration {
/** @return array<string, callable(Blueprint): mixed> */
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);
});
}
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
* Manager permission to set an actual quantity above the tolerance of a measured order line,
* assigned to the administrator role.
*
* @since 1.5.0
*/
return new class extends Migration {
/**
* PostgreSQL aborts the whole transaction after the first failed statement; the inserts
* below retry after duplicate-key races, so the migration runs without a transaction.
* Every step is idempotent.
*
* @var bool
*/
public $withinTransaction = false;

/** @var array<string, string> 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.
}
}
};
2 changes: 1 addition & 1 deletion docs/en/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions docs/en/developers/deliveries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/en/developers/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/en/developers/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Loading
Loading