diff --git a/changelog/60_3.0.0_2020-12-22.md b/changelog/60_3.0.0_2020-12-22.md
index 55eb56b4..e74d3993 100644
--- a/changelog/60_3.0.0_2020-12-22.md
+++ b/changelog/60_3.0.0_2020-12-22.md
@@ -46,6 +46,8 @@
- The amount to be used for the "quick consume/open buttons" on the stock overview page can now be configured per product (new product option "Quick consume amount", defaults to 1)
- This "Quick consume amount" can optionally also be used as the default on the consume page (new stock setting / top right corner settings menu)
- Products can now be duplicated (new button on the products list page, all fields will be preset from the copied product, except the name)
+- Products can now be merged (new button on the products list page)
+ - Useful if you have two products which are basically the same and want to replace all occurrences of one with the other one
- When consuming or opening a parent product, which is currently not in stock, any in-stock sub product will now be consumed/opened (like already automatically done when consuming recipes)
- Opened stock entries get now consumed first by default when no specific stock entry is used/selected
- So the default consume rule is now "Opened first, then first due first, then first in first out"
@@ -241,6 +243,7 @@
- New endpoints GET/POST/PUT `/users/{userId}/permissions` for the new user permissions feature mentioned above
- New endpoint `/user` to get the currently authenticated user
- New endpoint DELETE `/user/settings/{settingKey}` to delete a user setting
+- New endpoint POST `/stock/products/{productIdToKeep}/merge/{productIdToRemove}` for the new product merging feature mentioned above
- The following entities are now also available via the endpoint `/objects/{entity}` (only listing, no edit)
- `stock_log` (the stock journal)
- `stock` (the "raw" stock entries)
diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php
index 410cbf5a..efb4308e 100644
--- a/controllers/StockApiController.php
+++ b/controllers/StockApiController.php
@@ -775,6 +775,26 @@ class StockApiController extends BaseApiController
}
}
+ public function MergeProducts(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
+ {
+ User::checkPermission($request, User::PERMISSION_STOCK_EDIT);
+
+ try
+ {
+ if (!filter_var($args['productIdToKeep'], FILTER_VALIDATE_INT) || !filter_var($args['productIdToRemove'], FILTER_VALIDATE_INT))
+ {
+ throw new \Exception('Provided {productIdToKeep} or {productIdToRemove} is not a valid integer');
+ }
+
+ $this->ApiResponse($response, $this->getStockService()->MergeProducts($args['productIdToKeep'], $args['productIdToRemove']));
+ return $this->EmptyApiResponse($response);
+ }
+ catch (\Exception $ex)
+ {
+ return $this->GenericErrorResponse($response, $ex->getMessage());
+ }
+ }
+
public function __construct(\DI\Container $container)
{
parent::__construct($container);
diff --git a/grocy.openapi.json b/grocy.openapi.json
index 95307a28..bdaf3a7f 100644
--- a/grocy.openapi.json
+++ b/grocy.openapi.json
@@ -2178,6 +2178,49 @@
}
}
},
+ "/stock/products/{productIdToKeep}/merge/{productIdToRemove}": {
+ "post": {
+ "summary": "Merges two products into one",
+ "tags": [
+ "Stock"
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "productIdToKeep",
+ "required": true,
+ "description": "A valid product id of the product to keep",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "path",
+ "name": "productIdToRemove",
+ "required": true,
+ "description": "A valid product id of the product to remove",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "The operation was successful"
+ },
+ "400": {
+ "description": "The operation was not successful (possible errors are: Invalid product id)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error400"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/stock/products/by-barcode/{barcode}": {
"get": {
"summary": "Returns details of the given product by its barcode",
diff --git a/localization/strings.pot b/localization/strings.pot
index cf9f6c64..a9ccbfbf 100644
--- a/localization/strings.pot
+++ b/localization/strings.pot
@@ -2071,3 +2071,24 @@ msgstr ""
msgid "uk"
msgstr ""
+
+msgid "Merge this product with another one"
+msgstr ""
+
+msgid "Merge products"
+msgstr ""
+
+msgid "Product to keep"
+msgstr ""
+
+msgid "Product to remove"
+msgstr ""
+
+msgid "Error while merging products"
+msgstr ""
+
+msgid "After merging, this product will be kept"
+msgstr ""
+
+msgid "After merging, all occurences of this product will be replaced by \"Product to keep\" (means this product will not exist anymore)"
+msgstr ""
diff --git a/migrations/0120.sql b/migrations/0120.sql
index 7a469899..dbbac6e6 100644
--- a/migrations/0120.sql
+++ b/migrations/0120.sql
@@ -1,5 +1,8 @@
CREATE TRIGGER cascade_product_removal AFTER DELETE ON products
BEGIN
+ DELETE FROM stock
+ WHERE product_id = OLD.id;
+
DELETE FROM stock_log
WHERE product_id = OLD.id;
diff --git a/public/viewjs/products.js b/public/viewjs/products.js
index c32f5493..3d66e7de 100644
--- a/public/viewjs/products.js
+++ b/public/viewjs/products.js
@@ -102,3 +102,29 @@ if (GetUriParam('include_disabled'))
{
$("#show-disabled").prop('checked', true);
}
+
+
+$(".merge-products-button").on("click", function(e)
+{
+ var productId = $(e.currentTarget).attr("data-product-id");
+ $("#merge-products-keep").val(productId);
+ $("#merge-products-remove").val("");
+ $("#merge-products-modal").modal("show");
+});
+
+$("#merge-products-save-button").on("click", function()
+{
+ var productIdToKeep = $("#merge-products-keep").val();
+ var productIdToRemove = $("#merge-products-remove").val();
+
+ Grocy.Api.Post("stock/products/" + productIdToKeep.toString() + "/merge/" + productIdToRemove.toString(), {},
+ function(result)
+ {
+ window.location.href = U('/products');
+ },
+ function(xhr)
+ {
+ Grocy.FrontendHelpers.ShowGenericError('Error while merging products', xhr.response);
+ }
+ );
+});
diff --git a/routes.php b/routes.php
index 7d39a5ae..b7cb014c 100644
--- a/routes.php
+++ b/routes.php
@@ -193,6 +193,7 @@ $app->group('/api', function (RouteCollectorProxy $group) {
$group->post('/stock/products/{productId}/transfer', '\Grocy\Controllers\StockApiController:TransferProduct');
$group->post('/stock/products/{productId}/inventory', '\Grocy\Controllers\StockApiController:InventoryProduct');
$group->post('/stock/products/{productId}/open', '\Grocy\Controllers\StockApiController:OpenProduct');
+ $group->post('/stock/products/{productIdToKeep}/merge/{productIdToRemove}', '\Grocy\Controllers\StockApiController:MergeProducts');
$group->get('/stock/products/by-barcode/{barcode}', '\Grocy\Controllers\StockApiController:ProductDetailsByBarcode');
$group->post('/stock/products/by-barcode/{barcode}/add', '\Grocy\Controllers\StockApiController:AddProductByBarcode');
$group->post('/stock/products/by-barcode/{barcode}/consume', '\Grocy\Controllers\StockApiController:ConsumeProductByBarcode');
diff --git a/services/StockService.php b/services/StockService.php
index d6f20372..4741b4bc 100644
--- a/services/StockService.php
+++ b/services/StockService.php
@@ -1318,6 +1318,53 @@ class StockService extends BaseService
}
}
+ public function MergeProducts(int $productIdToKeep, int $productIdToRemove)
+ {
+ if (!$this->ProductExists($productIdToKeep))
+ {
+ throw new \Exception('$productIdToKeep does not exist or is inactive');
+ }
+
+ if (!$this->ProductExists($productIdToRemove))
+ {
+ throw new \Exception('$productIdToRemove does not exist or is inactive');
+ }
+
+ if ($productIdToKeep == $productIdToRemove)
+ {
+ throw new \Exception('$productIdToKeep cannot equal $productIdToRemove');
+ }
+
+ $this->getDatabaseService()->GetDbConnectionRaw()->beginTransaction();
+ try
+ {
+ $productToKeep = $this->getDatabase()->products($productIdToKeep);
+ $productToRemove = $this->getDatabase()->products($productIdToRemove);
+ $conversion = $this->getDatabase()->quantity_unit_conversions_resolved()->where('product_id = :1 AND from_qu_id = :2 AND to_qu_id = :3', $productToRemove->id, $productToRemove->qu_id_stock, $productToKeep->qu_id_stock)->fetch();
+ $factor = 1.0;
+ if ($conversion != null)
+ {
+ $factor = floatval($conversion->factor);
+ }
+
+ $this->getDatabaseService()->ExecuteDbStatement('UPDATE stock SET product_id = ' . $productIdToKeep . ', amount = amount * ' . $factor . ' WHERE product_id = ' . $productIdToRemove);
+ $this->getDatabaseService()->ExecuteDbStatement('UPDATE stock_log SET product_id = ' . $productIdToKeep . ', amount = amount * ' . $factor . ' WHERE product_id = ' . $productIdToRemove);
+ $this->getDatabaseService()->ExecuteDbStatement('UPDATE product_barcodes SET product_id = ' . $productIdToKeep . ' WHERE product_id = ' . $productIdToRemove);
+ $this->getDatabaseService()->ExecuteDbStatement('UPDATE quantity_unit_conversions SET product_id = ' . $productIdToKeep . ' WHERE product_id = ' . $productIdToRemove);
+ $this->getDatabaseService()->ExecuteDbStatement('UPDATE recipes_pos SET product_id = ' . $productIdToKeep . ', amount = amount * ' . $factor . ' WHERE product_id = ' . $productIdToRemove);
+ $this->getDatabaseService()->ExecuteDbStatement('UPDATE recipes SET product_id = ' . $productIdToKeep . ' WHERE product_id = ' . $productIdToRemove);
+ $this->getDatabaseService()->ExecuteDbStatement('UPDATE meal_plan SET product_id = ' . $productIdToKeep . ', product_amount = product_amount * ' . $factor . ' WHERE product_id = ' . $productIdToRemove);
+ $this->getDatabaseService()->ExecuteDbStatement('UPDATE shopping_list SET product_id = ' . $productIdToKeep . ', amount = amount * ' . $factor . ' WHERE product_id = ' . $productIdToRemove);
+ $this->getDatabaseService()->ExecuteDbStatement('DELETE FROM products WHERE id = ' . $productIdToRemove);
+ }
+ catch (Exception $ex)
+ {
+ $this->getDatabaseService()->GetDbConnectionRaw()->rollback();
+ throw $ex;
+ }
+ $this->getDatabaseService()->GetDbConnectionRaw()->commit();
+ }
+
private function LoadBarcodeLookupPlugin()
{
$pluginName = defined('GROCY_STOCK_BARCODE_LOOKUP_PLUGIN') ? GROCY_STOCK_BARCODE_LOOKUP_PLUGIN : '';
diff --git a/views/products.blade.php b/views/products.blade.php
index 668f8a90..686804f7 100644
--- a/views/products.blade.php
+++ b/views/products.blade.php
@@ -136,6 +136,13 @@
title="{{ $__t('Copy this item') }}">
+
+
+
+
+