New API endpoint for copying a product (fixes #2789)

This commit is contained in:
Bernd Bestel 2026-09-03 20:55:40 +02:00
parent 85dc601b37
commit 3c8edb515c
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
10 changed files with 142 additions and 87 deletions

View File

@ -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

View File

@ -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());
}
}
}

View File

@ -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",

View File

@ -2113,9 +2113,6 @@ msgstr[1] ""
msgid "Display product"
msgstr ""
msgid "Copy recipe"
msgstr ""
msgid "Copy of %s"
msgstr ""

View File

@ -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')
{

View File

@ -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);
}
);
});

View File

@ -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']);

View File

@ -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)

View File

@ -160,9 +160,10 @@
<i class="fa-solid fa-ellipsis-v"></i>
</button>
<div class="table-inline-menu dropdown-menu dropdown-menu-right">
<a class="dropdown-item"
<a class="dropdown-item product-copy-button"
data-product-id="{{ $product->id }}"
type="button"
href="{{ $U('/product/new?copy-of=') }}{{ $product->id }}">
href="#">
<span class="dropdown-item-text">{{ $__t('Copy') }}</span>
</a>
<a class="dropdown-item merge-products-button"

View File

@ -190,7 +190,7 @@
type="button"
href="#"
data-recipe-id="{{ $recipe->id }}">
<span class="dropdown-item-text">{{ $__t('Copy recipe') }}</span>
<span class="dropdown-item-text">{{ $__t('Copy') }}</span>
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item"