diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php index 6843ee57..4578f64d 100644 --- a/controllers/StockApiController.php +++ b/controllers/StockApiController.php @@ -83,9 +83,15 @@ class StockApiController extends BaseApiController $transactionType = $request->getQueryParams()['transactiontype']; } + $specificStockEntryId = "default"; + if (isset($request->getQueryParams()['stock_entry_id']) && !empty($request->getQueryParams()['stock_entry_id'])) + { + $specificStockEntryId = $request->getQueryParams()['stock_entry_id']; + } + try { - $bookingId = $this->StockService->ConsumeProduct($args['productId'], $args['amount'], $spoiled, $transactionType); + $bookingId = $this->StockService->ConsumeProduct($args['productId'], $args['amount'], $spoiled, $transactionType, $specificStockEntryId); return $this->ApiResponse(array('booking_id' => $bookingId)); } catch (\Exception $ex) @@ -178,4 +184,9 @@ class StockApiController extends BaseApiController return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage()); } } + + public function ProductStockEntries(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->ApiResponse($this->StockService->GetProductStockEntries($args['productId'])); + } } diff --git a/grocy.openapi.json b/grocy.openapi.json index 35fbd957..2438954e 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -968,6 +968,15 @@ "schema": { "$ref": "#/components/internalSchemas/StockTransactionType" } + }, + { + "in": "query", + "name": "stock_entry_id", + "required": false, + "description": "A specific stock entry id to consume, if used, the amount has to be 1", + "schema": { + "type": "string" + } } ], "responses": { @@ -1139,6 +1148,50 @@ } } }, + "/stock/get-product-stock-entries/{productId}": { + "get": { + "description": "Returns all stock entries of the given product in order of next use (first expiring first, then first in first out)", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "An array of StockEntry objects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockEntry" + } + } + } + } + }, + "400": { + "description": "A VoidApiActionResponse object (possible errors are: Not existing product)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse" + } + } + } + } + } + } + }, "/stock/get-current-stock": { "get": { "description": "Returns all products which are currently in stock incl. the next expiring date per product", diff --git a/localization/en/strings.php b/localization/en/strings.php index e6c91823..04895cba 100644 --- a/localization/en/strings.php +++ b/localization/en/strings.php @@ -305,5 +305,8 @@ return array( 'Disable stock fulfillment checking for this ingredient' => 'Disable stock fulfillment checking for this ingredient', 'Add all list items to stock' => 'Add all list items to stock', 'Add #3 #1 of #2 to stock' => 'Add #3 #1 of #2 to stock', - 'Adding shopping list item #1 of #2' => 'Adding shopping list item #1 of #2' + 'Adding shopping list item #1 of #2' => 'Adding shopping list item #1 of #2', + 'Use a specific stock item' => 'Use a specific stock item', + 'Expires on #1; bought on #2' => 'Expires on #1; bought on #2', + 'The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"' => 'The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"' ); diff --git a/public/viewjs/consume.js b/public/viewjs/consume.js index 2840c65c..1208b85a 100644 --- a/public/viewjs/consume.js +++ b/public/viewjs/consume.js @@ -4,18 +4,36 @@ var jsonForm = $('#consume-form').serializeJSON(); + if ($("#use_specific_stock_entry").is(":checked")) + { + jsonForm.amount = 1; + } + var spoiled = 0; if ($('#spoiled').is(':checked')) { spoiled = 1; } + var apiUrl = 'stock/consume-product/' + jsonForm.product_id + '/' + jsonForm.amount + '?spoiled=' + spoiled; + + if ($("#use_specific_stock_entry").is(":checked")) + { + apiUrl += "&stock_entry_id=" + jsonForm.specific_stock_entry; + } + Grocy.Api.Get('stock/get-product-details/' + jsonForm.product_id, function (productDetails) { - Grocy.Api.Get('stock/consume-product/' + jsonForm.product_id + '/' + jsonForm.amount + '?spoiled=' + spoiled, + Grocy.Api.Get(apiUrl, function(result) { + $("#specific_stock_entry").find("option").remove().end().append(""); + if ($("#use_specific_stock_entry").is(":checked")) + { + $("#use_specific_stock_entry").click(); + } + toastr.success(L('Removed #1 #2 of #3 from stock', jsonForm.amount, Pluralize(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + ' ' + L("Undo") + ''); $('#amount').val(1); @@ -38,8 +56,14 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e) { - var productId = $(e.target).val(); + $("#specific_stock_entry").find("option").remove().end().append(""); + if ($("#use_specific_stock_entry").is(":checked")) + { + $("#use_specific_stock_entry").click(); + } + var productId = $(e.target).val(); + if (productId) { Grocy.Components.ProductCard.Refresh(productId); @@ -69,6 +93,26 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e) console.error(xhr); } ); + + Grocy.Api.Get("stock/get-product-stock-entries/" + productId, + function (stockEntries) + { + stockEntries.forEach(stockEntry => + { + for (i = 0; i < stockEntry.amount; i++) + { + $("#specific_stock_entry").append($("", { + value: stockEntry.stock_id, + text: L("Expires on #1; bought on #2", moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD")) + })); + } + }); + }, + function(xhr) + { + console.error(xhr); + } + ); } }); @@ -81,7 +125,12 @@ $('#amount').on('focus', function(e) $(this).select(); }); -$('#consume-form input').keyup(function (event) +$('#consume-form input').keyup(function(event) +{ + Grocy.FrontendHelpers.ValidateForm('consume-form'); +}); + +$('#consume-form select').change(function(event) { Grocy.FrontendHelpers.ValidateForm('consume-form'); }); @@ -103,6 +152,27 @@ $('#consume-form input').keydown(function(event) } }); +$("#use_specific_stock_entry").on("change", function() +{ + var value = $(this).is(":checked"); + if (value) + { + $("#specific_stock_entry").removeAttr("disabled"); + $("#amount").attr("disabled", ""); + $("#amount").removeAttr("required"); + $("#specific_stock_entry").attr("required", ""); + } + else + { + $("#specific_stock_entry").attr("disabled", ""); + $("#amount").removeAttr("disabled"); + $("#amount").attr("required", ""); + $("#specific_stock_entry").removeAttr("required"); + } + + Grocy.FrontendHelpers.ValidateForm("consume-form"); +}); + function UndoStockBooking(bookingId) { Grocy.Api.Get('stock/undo-booking/' + bookingId.toString(), diff --git a/routes.php b/routes.php index d1baa9fb..17975a0a 100644 --- a/routes.php +++ b/routes.php @@ -111,6 +111,7 @@ $app->group('/api', function() $this->get('/stock/inventory-product/{productId}/{newAmount}', '\Grocy\Controllers\StockApiController:InventoryProduct'); $this->get('/stock/get-product-details/{productId}', '\Grocy\Controllers\StockApiController:ProductDetails'); $this->get('/stock/get-product-price-history/{productId}', '\Grocy\Controllers\StockApiController:ProductPriceHistory'); + $this->get('/stock/get-product-stock-entries/{productId}', '\Grocy\Controllers\StockApiController:ProductStockEntries'); $this->get('/stock/get-current-stock', '\Grocy\Controllers\StockApiController:CurrentStock'); $this->get('/stock/get-current-volatil-stock', '\Grocy\Controllers\StockApiController:CurrentVolatilStock'); $this->get('/stock/add-missing-products-to-shoppinglist', '\Grocy\Controllers\StockApiController:AddMissingProductsToShoppingList'); diff --git a/services/StockService.php b/services/StockService.php index 9c9d4af3..7c411f4d 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -91,6 +91,13 @@ class StockService extends BaseService return $returnData; } + public function GetProductStockEntries($productId) + { + // In order of next use: + // First expiring first, then first in first out + return $this->Database->stock()->where('product_id', $productId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll(); + } + public function AddProduct(int $productId, int $amount, string $bestBeforeDate, $transactionType, $purchasedDate, $price) { if (!$this->ProductExists($productId)) @@ -133,7 +140,7 @@ class StockService extends BaseService } } - public function ConsumeProduct(int $productId, int $amount, bool $spoiled, $transactionType) + public function ConsumeProduct(int $productId, int $amount, bool $spoiled, $transactionType, $specificStockEntryId = 'default') { if (!$this->ProductExists($productId)) { @@ -143,13 +150,18 @@ class StockService extends BaseService if ($transactionType === self::TRANSACTION_TYPE_CONSUME || $transactionType === self::TRANSACTION_TYPE_PURCHASE || $transactionType === self::TRANSACTION_TYPE_INVENTORY_CORRECTION) { $productStockAmount = $this->Database->stock()->where('product_id', $productId)->sum('amount'); - $potentialStockEntries = $this->Database->stock()->where('product_id', $productId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll(); //First expiring first, then first in first out + $potentialStockEntries = $this->GetProductStockEntries($productId); if ($amount > $productStockAmount) { return false; } + if ($specificStockEntryId !== 'default') + { + $potentialStockEntries = FindAllObjectsInArrayByPropertyValue($potentialStockEntries, 'stock_id', $specificStockEntryId); + } + foreach ($potentialStockEntries as $stockEntry) { if ($amount == 0) diff --git a/views/consume.blade.php b/views/consume.blade.php index 32e3fa60..214c7cbd 100644 --- a/views/consume.blade.php +++ b/views/consume.blade.php @@ -26,6 +26,16 @@ 'invalidFeedback' => $L('The amount cannot be lower than #1', '1') )) + + + {{ $L('Use a specific stock item') }} + + + + + {{ $L('The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"') }} + + {{ $L('Spoiled') }}