Make it possible to merge products (closes #243)

This commit is contained in:
Bernd Bestel 2020-12-20 20:58:22 +01:00
parent dadf93a94c
commit c9b5e14473
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
9 changed files with 220 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 : '';

View File

@ -136,6 +136,13 @@
title="{{ $__t('Copy this item') }}">
<i class="fas fa-copy"></i>
</a>
<a class="btn btn-primary btn-sm merge-products-button"
href="#"
data-product-id="{{ $product->id }}"
data-toggle="tooltip"
title="{{ $__t('Merge this product with another one') }}">
<i class="fas fa-compress-alt"></i>
</a>
<a class="btn btn-danger btn-sm product-delete-button"
href="#"
data-product-id="{{ $product->id }}"
@ -193,4 +200,53 @@
</table>
</div>
</div>
<div class="modal fade"
id="merge-products-modal"
tabindex="-1">
<div class="modal-dialog">
<div class="modal-content text-center">
<div class="modal-header">
<h4 class="modal-title w-100">{{ $__t('Merge products') }}</h4>
</div>
<div class="modal-body">
<div class="form-group">
<label for="merge-products-keep">{{ $__t('Product to keep') }}&nbsp;<i class="fas fa-question-circle text-muted"
data-toggle="tooltip"
title="{{ $__t('After merging, this product will be kept') }}"></i>
</label>
<select class="custom-control custom-select"
id="merge-products-keep">
<option></option>
@foreach($products as $product)
<option value="{{ $product->id }}">{{ $product->name }}</option>
@endforeach
</select>
</div>
<div class="form-group">
<label for="merge-products-remove">{{ $__t('Product to remove') }}&nbsp;<i class="fas fa-question-circle text-muted"
data-toggle="tooltip"
title="{{ $__t('After merging, all occurences of this product will be replaced by "Product to keep" (means this product will not exist anymore)') }}"></i>
</label>
<select class="custom-control custom-select"
id="merge-products-remove">
<option></option>
@foreach($products as $product)
<option value="{{ $product->id }}">{{ $product->name }}</option>
@endforeach
</select>
</div>
</div>
<div class="modal-footer">
<button type="button"
class="btn btn-secondary"
data-dismiss="modal">{{ $__t('Cancel') }}</button>
<button id="merge-products-save-button"
type="button"
class="btn btn-primary"
data-dismiss="modal">{{ $__t('OK') }}</button>
</div>
</div>
</div>
</div>
@stop