From 3c8edb515c5fdd9bc7df99dbab2cbed971a68161 Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Thu, 3 Sep 2026 20:55:40 +0200 Subject: [PATCH] New API endpoint for copying a product (fixes #2789) --- changelog/83_UNRELEASED_xxxx-xx-xx.md | 3 +- controllers/Api/StockApiController.php | 21 +++++++ grocy.openapi.json | 47 +++++++++++++++ localization/strings.pot | 3 - public/viewjs/productform.js | 81 +------------------------- public/viewjs/products.js | 18 ++++++ routes.php | 1 + services/StockService.php | 48 +++++++++++++++ views/products.blade.php | 5 +- views/recipes.blade.php | 2 +- 10 files changed, 142 insertions(+), 87 deletions(-) diff --git a/changelog/83_UNRELEASED_xxxx-xx-xx.md b/changelog/83_UNRELEASED_xxxx-xx-xx.md index 4a029c2e..1c6743ac 100644 --- a/changelog/83_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/83_UNRELEASED_xxxx-xx-xx.md @@ -10,7 +10,7 @@ ### Stock -- xxx +- Fixed that certain fields were not copied when copying a product ### Shopping list @@ -55,4 +55,5 @@ ### API +- New API endpoint `/stock/products/{productId}/copy` (to copy a product) - Fixed that API Key Authentication did not work when running Grocy in a subdirectory diff --git a/controllers/Api/StockApiController.php b/controllers/Api/StockApiController.php index 7229bc04..bd460566 100644 --- a/controllers/Api/StockApiController.php +++ b/controllers/Api/StockApiController.php @@ -926,4 +926,25 @@ class StockApiController extends BaseApiController return $this->GenericErrorResponse($response, $ex->getMessage()); } } + + public function CopyProduct(Request $request, Response $response, array $args) + { + User::CheckPermission($request, User::PERMISSION_STOCK_EDIT); + + try + { + if (filter_var($args['productId'], FILTER_VALIDATE_INT) === false) + { + throw new \Exception('Provided {productId} is not a valid integer'); + } + + return $this->ApiResponse($response, [ + 'created_object_id' => StockService::GetInstance()->CopyProduct($args['productId']) + ]); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } } diff --git a/grocy.openapi.json b/grocy.openapi.json index cb95a93e..026636e7 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -2333,6 +2333,53 @@ } } }, + "/stock/products/{productId}/copy": { + "post": { + "summary": "Copies a product", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id of the product to copy", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "The operation was successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "created_object_id": { + "type": "integer", + "description": "The id of the created product" + } + } + } + } + } + }, + "400": { + "description": "The operation was not successful (possible errors are: Invalid product id)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error400" + } + } + } + } + } + } + }, "/stock/products/{productIdToKeep}/merge/{productIdToRemove}": { "post": { "summary": "Merges two products into one", diff --git a/localization/strings.pot b/localization/strings.pot index 012dd0ef..8662342b 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2113,9 +2113,6 @@ msgstr[1] "" msgid "Display product" msgstr "" -msgid "Copy recipe" -msgstr "" - msgid "Copy of %s" msgstr "" diff --git a/public/viewjs/productform.js b/public/viewjs/productform.js index 06686f28..0eb384ab 100644 --- a/public/viewjs/productform.js +++ b/public/viewjs/productform.js @@ -392,86 +392,7 @@ $(window).on("message", function (e) } }); -if (Grocy.EditMode == "create" && GetUriParam("copy-of") != undefined) -{ - Grocy.Api.Get('objects/products/' + GetUriParam("copy-of"), - function (sourceProduct) - { - if (sourceProduct.parent_product_id != null) - { - Grocy.Components.ProductPicker.SetId(sourceProduct.parent_product_id); - } - if (sourceProduct.description) - { - $("#description").summernote("pasteHTML", sourceProduct.description); - } - $("#location_id").val(sourceProduct.location_id); - if (sourceProduct.shopping_location_id != null) - { - Grocy.Components.ShoppingLocationPicker.SetId(sourceProduct.shopping_location_id); - } - $("#min_stock_amount").val(sourceProduct.min_stock_amount); - if (BoolVal(sourceProduct.cumulate_min_stock_amount_of_sub_products)) - { - $("#cumulate_min_stock_amount_of_sub_products").prop("checked", true); - } - $("#default_best_before_days").val(sourceProduct.default_best_before_days); - $("#default_best_before_days_after_open").val(sourceProduct.default_best_before_days_after_open); - if (sourceProduct.product_group_id != null) - { - $("#product_group_id").val(sourceProduct.product_group_id); - } - $("#qu_id_stock").val(sourceProduct.qu_id_stock); - $("#qu_id_purchase").val(sourceProduct.qu_id_purchase); - if (BoolVal(sourceProduct.enable_tare_weight_handling)) - { - $("#enable_tare_weight_handling").prop("checked", true); - } - $("#tare_weight").val(sourceProduct.tare_weight); - if (BoolVal(sourceProduct.not_check_stock_fulfillment_for_recipes)) - { - $("#not_check_stock_fulfillment_for_recipes").prop("checked", true); - } - if (sourceProduct.calories != null) - { - $("#calories").val(sourceProduct.calories); - } - $("#default_best_before_days_after_freezing").val(sourceProduct.default_best_before_days_after_freezing); - $("#default_best_before_days_after_thawing").val(sourceProduct.default_best_before_days_after_thawing); - $("#quick_consume_amount").val(sourceProduct.quick_consume_amount); - $("#quick_open_amount").val(sourceProduct.quick_open_amount); - $("#default_consume_location_id").val(sourceProduct.default_consume_location_id); - if (BoolVal(sourceProduct.no_own_stock)) - { - $("#no_own_stock").prop("checked", true); - } - if (BoolVal(sourceProduct.hide_on_stock_overview)) - { - $("#hide_on_stock_overview").prop("checked", true); - } - if (BoolVal(sourceProduct.auto_reprint_stock_label)) - { - $("#auto_reprint_stock_label").prop("checked", true); - } - $("#default_stock_label_type").val(sourceProduct.default_stock_label_type); - if (BoolVal(sourceProduct.move_on_open)) - { - $("#move_on_open").prop("checked", true); - } - if (BoolVal(sourceProduct.treat_opened_as_out_of_stock)) - { - $("#treat_opened_as_out_of_stock").prop("checked", true); - } - - Grocy.FrontendHelpers.ValidateForm('product-form'); - }, - function (xhr) - { - console.error(xhr); - } - ); -} -else if (Grocy.EditMode === 'create') +if (Grocy.EditMode === 'create') { if (Grocy.UserSettings.product_presets_location_id.toString() !== '-1') { diff --git a/public/viewjs/products.js b/public/viewjs/products.js index 89c017cf..bd3459fb 100644 --- a/public/viewjs/products.js +++ b/public/viewjs/products.js @@ -175,3 +175,21 @@ $("#merge-products-save-button").on("click", function (e) } ); }); + +$(".product-copy-button").on('click', function (e) +{ + e.preventDefault(); + + var objectId = $(e.currentTarget).attr("data-product-id"); + + Grocy.Api.Post("stock/products/" + objectId.toString() + "/copy", {}, + function (result) + { + window.location.href = U("/product/" + result.created_object_id.toString()); + }, + function (xhr) + { + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response); + } + ); +}); diff --git a/routes.php b/routes.php index 33b9f9bc..880aa7a1 100644 --- a/routes.php +++ b/routes.php @@ -207,6 +207,7 @@ $app->group('/api', function (RouteCollectorProxy $group) $group->post('/stock/products/{productId}/inventory', [StockApiController::class, 'InventoryProduct']); $group->post('/stock/products/{productId}/open', [StockApiController::class, 'OpenProduct']); $group->post('/stock/products/{productIdToKeep}/merge/{productIdToRemove}', [StockApiController::class, 'MergeProducts']); + $group->post('/stock/products/{productId}/copy', [StockApiController::class, 'CopyProduct']); $group->get('/stock/products/by-barcode/{barcode}', [StockApiController::class, 'ProductDetailsByBarcode']); $group->post('/stock/products/by-barcode/{barcode}/add', [StockApiController::class, 'AddProductByBarcode']); $group->post('/stock/products/by-barcode/{barcode}/consume', [StockApiController::class, 'ConsumeProductByBarcode']); diff --git a/services/StockService.php b/services/StockService.php index c0abdab0..344dc1ce 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -1730,6 +1730,54 @@ class StockService extends BaseService DatabaseService::GetInstance()->GetDbConnectionRaw()->commit(); } + public function CopyProduct(int $productId) + { + if (!$this->ProductExists($productId)) + { + throw new \Exception('Product does not exist'); + } + + $newName = LocalizationService::GetInstance()->__t('Copy of %s', $this->DB->products($productId)->name); + + DatabaseService::GetInstance()->ExecuteDbStatement( + 'INSERT INTO products + (name, description, product_group_id, active, location_id, shopping_location_id, qu_id_purchase, qu_id_stock, min_stock_amount, + default_best_before_days, default_best_before_days_after_open, default_best_before_days_after_freezing, + default_best_before_days_after_thawing, picture_file_name, enable_tare_weight_handling, tare_weight, not_check_stock_fulfillment_for_recipes, + parent_product_id, calories, cumulate_min_stock_amount_of_sub_products, due_type, quick_consume_amount, hide_on_stock_overview, default_stock_label_type, + should_not_be_frozen, treat_opened_as_out_of_stock, no_own_stock, default_consume_location_id, move_on_open, row_created_timestamp, qu_id_consume, + auto_reprint_stock_label, quick_open_amount, qu_id_price, disable_open, default_purchase_price_type) + SELECT :new_name, description, product_group_id, active, location_id, shopping_location_id, qu_id_purchase, qu_id_stock, min_stock_amount, + default_best_before_days, default_best_before_days_after_open, default_best_before_days_after_freezing, + default_best_before_days_after_thawing, picture_file_name, enable_tare_weight_handling, tare_weight, not_check_stock_fulfillment_for_recipes, + parent_product_id, calories, cumulate_min_stock_amount_of_sub_products, due_type, quick_consume_amount, hide_on_stock_overview, default_stock_label_type, + should_not_be_frozen, treat_opened_as_out_of_stock, no_own_stock, default_consume_location_id, move_on_open, row_created_timestamp, qu_id_consume, + auto_reprint_stock_label, quick_open_amount, qu_id_price, disable_open, default_purchase_price_type + FROM products + WHERE id = :product_id', + ['product_id' => $productId, 'new_name' => $newName] + ); + $lastInsertId = $this->DB->lastInsertId(); + + DatabaseService::GetInstance()->ExecuteDbStatement( + 'INSERT INTO quantity_unit_conversions + (product_id, from_qu_id, to_qu_id, factor) + SELECT :last_insert_id, from_qu_id, to_qu_id, factor + FROM quantity_unit_conversions q + WHERE IFNULL(q.product_id, 0) = :product_id + AND q.id = ( + SELECT MIN(q2.id) + FROM quantity_unit_conversions q2 + WHERE IFNULL(q2.product_id, 0) = :product_id + AND MIN(q2.from_qu_id, q2.to_qu_id) = MIN(q.from_qu_id, q.to_qu_id) + AND MAX(q2.from_qu_id, q2.to_qu_id) = MAX(q.from_qu_id, q.to_qu_id) + )', // The corresponding inverse conversion is automatically created by a trigger + ['product_id' => $productId, 'last_insert_id' => $lastInsertId] + ); + + return $lastInsertId; + } + public function CompactStockEntries($productId = null) { if ($productId == null) diff --git a/views/products.blade.php b/views/products.blade.php index 0fa3b3c1..9f871b0f 100644 --- a/views/products.blade.php +++ b/views/products.blade.php @@ -160,9 +160,10 @@