From c9b5e14473bc1a0597dfd2b068c3c8145a036474 Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Sun, 20 Dec 2020 20:58:22 +0100 Subject: [PATCH] Make it possible to merge products (closes #243) --- changelog/60_3.0.0_2020-12-22.md | 3 ++ controllers/StockApiController.php | 20 +++++++++++ grocy.openapi.json | 43 +++++++++++++++++++++++ localization/strings.pot | 21 +++++++++++ migrations/0120.sql | 3 ++ public/viewjs/products.js | 26 ++++++++++++++ routes.php | 1 + services/StockService.php | 47 +++++++++++++++++++++++++ views/products.blade.php | 56 ++++++++++++++++++++++++++++++ 9 files changed, 220 insertions(+) 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') }}"> + + + + + @stop