diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php index 634793c2..883540a9 100644 --- a/controllers/StockApiController.php +++ b/controllers/StockApiController.php @@ -113,6 +113,60 @@ class StockApiController extends BaseApiController } } + public function TransferProduct(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + $requestBody = $request->getParsedBody(); + + try + { + if ($requestBody === null) + { + throw new \Exception('Request body could not be parsed (probably invalid JSON format or missing/wrong Content-Type header)'); + } + + if (!array_key_exists('amount', $requestBody)) + { + throw new \Exception('An amount is required'); + } + + if (!array_key_exists('location_id_from', $requestBody)) + { + throw new \Exception('A transfer from location is required'); + } + + if (!array_key_exists('location_id_to', $requestBody)) + { + throw new \Exception('A transfer to location is required'); + } + + $specificStockEntryId = 'default'; + if (array_key_exists('stock_entry_id', $requestBody) && !empty($requestBody['stock_entry_id'])) + { + $specificStockEntryId = $requestBody['stock_entry_id']; + } + + $bookingId = $this->StockService->TransferProduct($args['productId'], $requestBody['amount'], $requestBody['location_id_to'], $requestBody['location_id_from'], $specificStockEntryId); + return $this->ApiResponse($this->Database->stock_log($bookingId)); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + + public function TransferProductByBarcode(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + try + { + $args['productId'] = $this->StockService->GetProductIdFromBarcode($args['barcode']); + return $this->TransferProduct($request, $response, $args); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + public function ConsumeProduct(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { $requestBody = $request->getParsedBody(); @@ -454,6 +508,11 @@ class StockApiController extends BaseApiController return $this->ApiResponse($this->StockService->GetProductStockEntries($args['productId'])); } + public function ProductStockLocations(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->ApiResponse($this->StockService->GetProductStockLocations($args['productId'])); + } + public function StockBooking(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { try diff --git a/controllers/StockController.php b/controllers/StockController.php index 1b763c56..470f3eff 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -55,6 +55,15 @@ class StockController extends BaseController ]); } + public function Transfer(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->AppContainer->view->render($response, 'transfer', [ + 'products' => $this->Database->products()->orderBy('name'), + 'recipes' => $this->Database->recipes()->orderBy('name'), + 'locations' => $this->Database->locations()->orderBy('name') + ]); + } + public function Inventory(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { return $this->AppContainer->view->render($response, 'inventory', [ @@ -272,6 +281,7 @@ class StockController extends BaseController { return $this->AppContainer->view->render($response, 'stockjournal', [ 'stockLog' => $this->Database->stock_log()->orderBy('row_created_timestamp', 'DESC'), + 'locations' => $this->Database->locations()->orderBy('name'), 'products' => $this->Database->products()->orderBy('name'), 'quantityunits' => $this->Database->quantity_units()->orderBy('name') ]); diff --git a/grocy.openapi.json b/grocy.openapi.json index 77036fb8..0a96f7d9 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -1128,6 +1128,50 @@ } } }, + "/stock/products/{productId}/locations": { + "get": { + "summary": "Returns all locations with current stock", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "An array of StockLocation objects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockLocation" + } + } + } + } + }, + "400": { + "description": "The operation was not successful (possible errors are: Not existing product)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } + } + }, "/stock/products/{productId}/entries": { "get": { "summary": "Returns all stock entries of the given product in order of next use (first expiring first, then first in first out)", @@ -1376,6 +1420,86 @@ } } }, + "/stock/products/{productId}/transfer": { + "post": { + "summary": "Transfer the given amount of the given product from stock from one location to another", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "description": "The amount to remove - please note that when tare weight handling for the product is enabled, this needs to be the amount including the container weight (gross), the amount to be posted will be automatically calculated based on what is in stock and the defined tare weight" + }, + "transaction_type": { + "$ref": "#/components/internalSchemas/StockTransactionType" + }, + "location_from_id": { + "type": "number", + "format": "integer", + "description": "Location the stock is transfering from" + }, + "location_to_id": { + "type": "number", + "format": "integer", + "description": "Location the stock is transfering to" + }, + "stock_entry_id": { + "type": "string", + "description": "A specific stock entry id to consume, if used, the amount has to be 1" + } + }, + "example": { + "amount": 1, + "transaction_type": "transfer", + "location_from_id": 1, + "location_to_id": 2 + } + } + } + } + }, + "responses": { + "200": { + "description": "The operation was successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockLogEntry" + } + } + } + }, + "400": { + "description": "The operation was not successful (possible errors are: Not existing product, invalid transaction type, given amount > current stock amount)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } + } + }, "/stock/products/{productId}/inventory": { "post": { "summary": "Inventories the given product (adds/removes based on the given new amount)", @@ -3023,6 +3147,29 @@ "row_created_timestamp": "2019-05-02 20:12:25" } }, + "StockLocation": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "product_id": { + "type": "integer" + }, + "location_id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "example": { + "id": "1", + "product_id": "3", + "location_id": "1", + "name": "Fridge" + } + }, "StockEntry": { "type": "object", "properties": { diff --git a/localization/en/stock_transaction_types.po b/localization/en/stock_transaction_types.po index 7242c79e..26fecbdc 100644 --- a/localization/en/stock_transaction_types.po +++ b/localization/en/stock_transaction_types.po @@ -15,6 +15,12 @@ msgstr "" msgid "purchase" msgstr "Purchase" +msgid "transfer_to" +msgstr "Transfer To" + +msgid "transfer_from" +msgstr "Transfer From" + msgid "consume" msgstr "Consume" diff --git a/localization/stock_transaction_types.pot b/localization/stock_transaction_types.pot index 9f89fc48..b3ce24e9 100644 --- a/localization/stock_transaction_types.pot +++ b/localization/stock_transaction_types.pot @@ -15,6 +15,12 @@ msgstr "" msgid "purchase" msgstr "" +msgid "transfer_from" +msgstr "" + +msgid "transfer_to" +msgstr "" + msgid "consume" msgstr "" diff --git a/localization/strings.pot b/localization/strings.pot index ac91e926..684b553b 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -1567,3 +1567,15 @@ msgstr "" msgid "This means the next execution of this chore should only be scheduled every %s years" msgstr "" + +msgid "Transfer" +msgstr "" + +msgid "From location" +msgstr "" + +msgid "To location" +msgstr "" + +msgid "There are no units available at this location" +msgstr "" diff --git a/migrations/0095.sql b/migrations/0095.sql new file mode 100644 index 00000000..dade801a --- /dev/null +++ b/migrations/0095.sql @@ -0,0 +1,11 @@ +DROP VIEW stock_current_locations; +CREATE VIEW stock_current_locations AS +SELECT + s.id, + s.product_id, + IFNULL(s.location_id, p.location_id) AS location_id, + l.name AS name + FROM stock s + JOIN products p ON s.product_id = p.id + JOIN locations l on IFNULL(s.location_id, p.location_id) = l.id +GROUP BY s.product_id, IFNULL(s.location_id, p.location_id) diff --git a/public/viewjs/stockoverview.js b/public/viewjs/stockoverview.js index 1f7b2a95..b0404981 100644 --- a/public/viewjs/stockoverview.js +++ b/public/viewjs/stockoverview.js @@ -219,6 +219,30 @@ $(document).on("click", ".product-purchase-button", function(e) }); }); +$(document).on("click", ".product-transfer-custom-amount-button", function(e) +{ + e.preventDefault(); + + var productId = $(e.currentTarget).attr("data-product-id"); + + bootbox.dialog({ + message: '', + size: 'large', + backdrop: true, + closeButton: false, + buttons: { + cancel: { + label: __t('Cancel'), + className: 'btn-secondary responsive-button', + callback: function() + { + bootbox.hideAll(); + } + } + } + }); +}); + $(document).on("click", ".product-consume-custom-amount-button", function(e) { e.preventDefault(); diff --git a/public/viewjs/transfer.js b/public/viewjs/transfer.js new file mode 100644 index 00000000..df54fa5c --- /dev/null +++ b/public/viewjs/transfer.js @@ -0,0 +1,375 @@ +$('#save-transfer-button').on('click', function(e) +{ + e.preventDefault(); + + var jsonForm = $('#transfer-form').serializeJSON(); + Grocy.FrontendHelpers.BeginUiBusy("transfer-form"); + + var apiUrl = 'stock/products/' + jsonForm.product_id + '/transfer'; + + var jsonData = {}; + jsonData.amount = jsonForm.amount; + jsonData.location_id_to = $("#location_id_to").val(); + jsonData.location_id_from = $("#location_id_from").val(); + + if ($("#use_specific_stock_entry").is(":checked")) + { + jsonData.stock_entry_id = jsonForm.specific_stock_entry; + } + + Grocy.Api.Get('stock/products/' + jsonForm.product_id, + function(productDetails) + { + Grocy.Api.Post(apiUrl, jsonData, + function(result) + { + var addBarcode = GetUriParam('addbarcodetoselection'); + if (addBarcode !== undefined) + { + var existingBarcodes = productDetails.product.barcode || ''; + if (existingBarcodes.length === 0) + { + productDetails.product.barcode = addBarcode; + } + else + { + productDetails.product.barcode += ',' + addBarcode; + } + + Grocy.Api.Put('objects/products/' + productDetails.product.id, productDetails.product, + function(result) + { + $("#flow-info-addbarcodetoselection").addClass("d-none"); + $('#barcode-lookup-disabled-hint').addClass('d-none'); + window.history.replaceState({ }, document.title, U("/transfer")); + }, + function(xhr) + { + console.error(xhr); + } + ); + } + + if (productDetails.product.enable_tare_weight_handling == 1) + { + var successMessage = __t('Transfered %1$s of %2$s stock from %3$s to %4$s', Math.abs(jsonForm.amount - parseFloat(productDetails.product.tare_weight)) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name,$('option:selected', "#location_id_from").text(), $('option:selected', "#location_id_to").text()); + } + else + { + var successMessage =__t('Transfered %1$s of %2$s stock from %3$s to %4$s', Math.abs(jsonForm.amount) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name, $('option:selected', "#location_id_from").text(), $('option:selected', "#location_id_to").text()); + } + + if (GetUriParam("embedded") !== undefined) + { + window.parent.postMessage(WindowMessageBag("ProductChanged", jsonForm.product_id), Grocy.BaseUrl); + window.parent.postMessage(WindowMessageBag("ShowSuccessMessage", successMessage), Grocy.BaseUrl); + window.parent.postMessage(WindowMessageBag("CloseAllModals"), Grocy.BaseUrl); + } + else + { + + Grocy.FrontendHelpers.EndUiBusy("transfer-form"); + toastr.success(successMessage); + + $("#specific_stock_entry").find("option").remove().end().append(""); + $("#specific_stock_entry").attr("disabled", ""); + $("#specific_stock_entry").removeAttr("required"); + if ($("#use_specific_stock_entry").is(":checked")) + { + $("#use_specific_stock_entry").click(); + } + + $("#location_id_from").find("option").remove().end().append(""); + $("#amount").attr("min", "1"); + $("#amount").attr("max", "999999"); + $("#amount").attr("step", "1"); + $("#amount").parent().find(".invalid-feedback").text(__t('The amount cannot be lower than %s', '1')); + $('#amount').val(Grocy.UserSettings.stock_default_transfer_amount); + $('#amount_qu_unit').text(""); + $("#tare-weight-handling-info").addClass("d-none"); + Grocy.Components.ProductPicker.Clear(); + $("#location_id_to").val(""); + $("#location_id_from").val(""); + Grocy.Components.ProductPicker.GetInputElement().focus(); + Grocy.FrontendHelpers.ValidateForm('transfer-form'); + } + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("transfer-form"); + console.error(xhr); + } + ); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("transfer-form"); + console.error(xhr); + } + ); +}); + +Grocy.Components.ProductPicker.GetPicker().on('change', function(e) +{ + $("#specific_stock_entry").find("option").remove().end().append(""); + if ($("#use_specific_stock_entry").is(":checked")) + { + $("#use_specific_stock_entry").click(); + } + $("#location_id_to").val(""); + $("#location_id_from").val(""); + + var productId = $(e.target).val(); + + if (productId) + { + Grocy.Components.ProductCard.Refresh(productId); + + Grocy.Api.Get('stock/products/' + productId, + function(productDetails) + { + if (productDetails.product.enable_tare_weight_handling == 1) { + Grocy.Components.ProductPicker.GetPicker().parent().find(".invalid-feedback").text(__t('Products with Tare weight enabled are currently not supported for Transfer. Please select another product.')); + Grocy.Components.ProductPicker.Clear(); + return; + } + $('#amount_qu_unit').text(productDetails.quantity_unit_stock.name); + + $("#location_id_from").find("option").remove().end().append(""); + Grocy.Api.Get("stock/products/" + productId + '/locations', + function(stockLocations) + { + var setDefault = 0; + stockLocations.forEach(stockLocation => + { + if (productDetails.location.id == stockLocation.location_id) { + $("#location_id_from").append($(""); + if ($("#use_specific_stock_entry").is(":checked")) + { + $("#use_specific_stock_entry").click(); + } + + if (locationId) + { + Grocy.Api.Get("stock/products/" + Grocy.Components.ProductPicker.GetValue() + '/entries', + function(stockEntries) + { + stockEntries.forEach(stockEntry => + { + if (stockEntry.location_id == locationId) + { + $("#specific_stock_entry").append($("