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($("", { + value: stockLocation.location_id, + text: __t("%1$s (default location)", stockLocation.name) + })); + $("#location_id_from").val(productDetails.location.id); + $("#location_id_from").trigger('change'); + setDefault = 1; + } else { + $("#location_id_from").append($("", { + value: stockLocation.location_id, + text: __t("%1$s", stockLocation.name) + })); + } + if (setDefault == 0) { + $("#location_id_from").val(stockLocation.location_id); + $("#location_id_from").trigger('change'); + } + }); + }, + function(xhr) + { + console.error(xhr); + } + ); + + if (productDetails.product.allow_partial_units_in_stock == 1) + { + $("#amount").attr("min", "0.01"); + $("#amount").attr("step", "0.01"); + $("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', 0.01.toLocaleString(), parseFloat(productDetails.stock_amount).toLocaleString())); + } + else + { + $("#amount").attr("min", "1"); + $("#amount").attr("step", "1"); + $("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", parseFloat(productDetails.stock_amount).toLocaleString())); + } + + if (productDetails.product.enable_tare_weight_handling == 1) + { + $("#amount").attr("min", productDetails.product.tare_weight); + $("#tare-weight-handling-info").removeClass("d-none"); + } + else + { + $("#tare-weight-handling-info").addClass("d-none"); + } + + if ((parseFloat(productDetails.stock_amount) || 0) === 0) + { + Grocy.Components.ProductPicker.Clear(); + Grocy.FrontendHelpers.ValidateForm('transfer-form'); + Grocy.Components.ProductPicker.ShowCustomError(__t('This product is not in stock')); + Grocy.Components.ProductPicker.GetInputElement().focus(); + } + else + { + Grocy.Components.ProductPicker.HideCustomError(); + Grocy.FrontendHelpers.ValidateForm('transfer-form'); + $('#amount').focus(); + } + }, + function(xhr) + { + console.error(xhr); + } + ); + } +}); + +$('#amount').val(Grocy.UserSettings.stock_default_transfer_amount); +Grocy.Components.ProductPicker.GetPicker().trigger('change'); +Grocy.Components.ProductPicker.GetInputElement().focus(); +Grocy.FrontendHelpers.ValidateForm('transfer-form'); + +$("#location_id_from").on('change', function(e) +{ + var locationId = $(e.target).val(); + var sumValue = 0; + + if (locationId == $("#location_id_to").val()) + { + $("#location_id_to").val(""); + } + + $("#specific_stock_entry").find("option").remove().end().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($("", { + value: stockEntry.stock_id, + amount: stockEntry.amount, + text: __t("Amount remaining: %1$s, Best Before: %2$s", stockEntry.amount, stockEntry.best_before_date) + })); + sumValue = sumValue + parseFloat(stockEntry.amount); + } + + if (stockEntry.location_id === null) + { + $("#specific_stock_entry").append($("", { + value: stockEntry.stock_id, + amount: stockEntry.amount, + text: __t("Amount remaining: %1$s, Best Before: %2$s", stockEntry.amount, stockEntry.best_before_date) + })); + sumValue = sumValue + parseFloat(stockEntry.amount); + } + }); + $("#amount").attr("max", sumValue); + if (sumValue == 0) + { + $("#amount").parent().find(".invalid-feedback").text(__t('There are no units available at this location')); + } else { + $("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", sumValue)); + } + }, + function(xhr) + { + console.error(xhr); + } + ); + } +}); + +$("#location_id_to").on('change', function(e) +{ + var locationId = $(e.target).val(); + + if (locationId == $("#location_id_from").val()) + { + $("#location_id_to").parent().find(".invalid-feedback").text(__t('This cannot be the same as the "From" location')); + $("#location_id_to").val(""); + } +}); + +$('#amount').on('focus', function(e) +{ + $(this).select(); +}); + +$('#transfer-form input').keyup(function(event) +{ + Grocy.FrontendHelpers.ValidateForm('transfer-form'); +}); + +$('#transfer-form select').change(function(event) +{ + Grocy.FrontendHelpers.ValidateForm('transfer-form'); +}); + +$('#transfer-form input').keydown(function(event) +{ + if (event.keyCode === 13) //Enter + { + event.preventDefault(); + + if (document.getElementById('transfer-form').checkValidity() === false) //There is at least one validation error + { + return false; + } + else + { + $('#save-transfer-button').click(); + } + } +}); + +$("#specific_stock_entry").on("change", function(e) +{ + if ($(e.target).val() == "") + { + var sumValue = 0; + Grocy.Api.Get("stock/products/" + Grocy.Components.ProductPicker.GetValue() + '/entries', + function(stockEntries) + { + stockEntries.forEach(stockEntry => + { + if (stockEntry.location_id == $("#location_id_from").val() || stockEntry.location_id == "") + { + sumValue = sumValue + parseFloat(stockEntry.amount); + } + }); + $("#amount").attr("max", sumValue); + if (sumValue == 0) + { + $("#amount").parent().find(".invalid-feedback").text(__t('There are no units available at this location')); + } else { + $("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", sumValue)); + } + }, + function(xhr) + { + console.error(xhr); + } + ); + } else { + $("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", $('option:selected', this).attr('amount'))); + $("#amount").attr("max", $('option:selected', this).attr('amount')); + } +}); + +$("#use_specific_stock_entry").on("change", function() +{ + var value = $(this).is(":checked"); + + if (value) + { + $("#specific_stock_entry").removeAttr("disabled"); + $("#specific_stock_entry").attr("required", ""); + } + else + { + $("#specific_stock_entry").attr("disabled", ""); + $("#specific_stock_entry").removeAttr("required"); + $("#specific_stock_entry").val(""); + $("#location_id_from").trigger('change'); + } + + Grocy.FrontendHelpers.ValidateForm("transfer-form"); +}); diff --git a/routes.php b/routes.php index 53541d2c..6e83a4e6 100644 --- a/routes.php +++ b/routes.php @@ -35,6 +35,7 @@ $app->group('', function() $this->get('/stockoverview', '\Grocy\Controllers\StockController:Overview'); $this->get('/purchase', '\Grocy\Controllers\StockController:Purchase'); $this->get('/consume', '\Grocy\Controllers\StockController:Consume'); + $this->get('/transfer', '\Grocy\Controllers\StockController:Transfer'); $this->get('/inventory', '\Grocy\Controllers\StockController:Inventory'); $this->get('/products', '\Grocy\Controllers\StockController:ProductsList'); $this->get('/product/{productId}', '\Grocy\Controllers\StockController:ProductEditForm'); @@ -161,14 +162,17 @@ $app->group('/api', function() $this->get('/stock/volatile', '\Grocy\Controllers\StockApiController:CurrentVolatilStock'); $this->get('/stock/products/{productId}', '\Grocy\Controllers\StockApiController:ProductDetails'); $this->get('/stock/products/{productId}/entries', '\Grocy\Controllers\StockApiController:ProductStockEntries'); + $this->get('/stock/products/{productId}/locations', '\Grocy\Controllers\StockApiController:ProductStockLocations'); $this->get('/stock/products/{productId}/price-history', '\Grocy\Controllers\StockApiController:ProductPriceHistory'); $this->post('/stock/products/{productId}/add', '\Grocy\Controllers\StockApiController:AddProduct'); $this->post('/stock/products/{productId}/consume', '\Grocy\Controllers\StockApiController:ConsumeProduct'); + $this->post('/stock/products/{productId}/transfer', '\Grocy\Controllers\StockApiController:TransferProduct'); $this->post('/stock/products/{productId}/inventory', '\Grocy\Controllers\StockApiController:InventoryProduct'); $this->post('/stock/products/{productId}/open', '\Grocy\Controllers\StockApiController:OpenProduct'); $this->get('/stock/products/by-barcode/{barcode}', '\Grocy\Controllers\StockApiController:ProductDetailsByBarcode'); $this->post('/stock/products/by-barcode/{barcode}/add', '\Grocy\Controllers\StockApiController:AddProductByBarcode'); $this->post('/stock/products/by-barcode/{barcode}/consume', '\Grocy\Controllers\StockApiController:ConsumeProductByBarcode'); + $this->post('/stock/products/by-barcode/{barcode}/transfer', '\Grocy\Controllers\StockApiController:TransferProductByBarcode'); $this->post('/stock/products/by-barcode/{barcode}/inventory', '\Grocy\Controllers\StockApiController:InventoryProductByBarcode'); $this->post('/stock/products/by-barcode/{barcode}/open', '\Grocy\Controllers\StockApiController:OpenProductByBarcode'); $this->get('/stock/bookings/{bookingId}', '\Grocy\Controllers\StockApiController:StockBooking'); diff --git a/services/StockService.php b/services/StockService.php index fed2c8ef..35ca5140 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -6,6 +6,8 @@ class StockService extends BaseService { const TRANSACTION_TYPE_PURCHASE = 'purchase'; const TRANSACTION_TYPE_CONSUME = 'consume'; + const TRANSACTION_TYPE_TRANSFER_FROM = 'transfer_from'; + const TRANSACTION_TYPE_TRANSFER_TO = 'transfer_to'; const TRANSACTION_TYPE_INVENTORY_CORRECTION = 'inventory-correction'; const TRANSACTION_TYPE_PRODUCT_OPENED = 'product-opened'; @@ -176,7 +178,12 @@ class StockService extends BaseService } } - public function GetProductStockEntriesByLocation($productId, $locationId, $excludeOpened = false) + public function GetProductStockLocations($productId) + { + return $this->Database->stock_current_locations()->where('product_id', $productId)->fetchAll(); + } + + public function GetProductStockEntriesByLocation($productId, $locationId, $checkNullLocation = false, $excludeOpened = false) { // In order of next use: // First expiring first, then first in first out @@ -185,9 +192,11 @@ class StockService extends BaseService { return $this->Database->stock()->where('product_id = :1 AND location_id = :2 AND open = 0', $productId, $locationId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll(); } - else + else if ($checkNullLocation) { - return $this->Database->stock()->where('product_id = :1 AND location_id = :2', $productId, $locationId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll(); + return $this->Database->stock()->where('product_id = :1 AND (location_id = :2 OR location_id is null)', $productId, $locationId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll(); + } else { + return $this->Database->stock()->where('product_id = :1 AND location_id = :2', $productId, $locationId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll(); } } @@ -229,7 +238,7 @@ class StockService extends BaseService } } - if ($transactionType === self::TRANSACTION_TYPE_PURCHASE || $transactionType === self::TRANSACTION_TYPE_INVENTORY_CORRECTION) + if ($transactionType === self::TRANSACTION_TYPE_PURCHASE || $transactionType === self::TRANSACTION_TYPE_INVENTORY_CORRECTION || $transactionType === self::TRANSACTION_TYPE_TRANSFER_TO) { //Check to see if this is already in stock at this location $stockRows = $this->Database->stock()->where('product_id = :1 AND best_before_date = :2 AND purchased_date = :3 AND price = :4', $productId, $bestBeforeDate, $purchasedDate, $price)->fetchAll(); @@ -327,6 +336,98 @@ class StockService extends BaseService } } + public function TransferProduct(int $productId, float $amount, int $locationId, int $locationFromId, $specificStockEntryId = 'default') + { + if (!$this->ProductExists($productId)) + { + throw new \Exception('Product does not exist'); + } + if (!$this->LocationExists($locationId)) + { + throw new \Exception('Transfer location does not exist'); + } + + // Tare weight handling + // The given amount is the new total amount including the container weight (gross) + // The amount to be posted needs to be the absolute value of the given amount - stock amount - tare weight + $productDetails = (object)$this->GetProductDetails($productId); + if ($productDetails->product->enable_tare_weight_handling == 1) + { + if ($amount < floatval($productDetails->product->tare_weight)) + { + throw new \Exception('The amount cannot be lower than the defined tare weight'); + } + + $amount = abs($amount - floatval($productDetails->stock_amount) - floatval($productDetails->product->tare_weight)); + } + + $checkNullLocation = $productDetails->product->location_id == $locationFromId; + $potentialStockEntries = $this->GetProductStockEntriesByLocation($productId, $locationFromId, $checkNullLocation); + + if ($specificStockEntryId !== 'default') + { + $potentialStockEntries = FindAllObjectsInArrayByPropertyValue($potentialStockEntries, 'stock_id', $specificStockEntryId); + } + + if ($checkNullLocation) + { + $productStockAmountByLocation = $this->Database->stock()->where('product_id = :1 AND (location_id = :2 OR location_id is null)', $productId, $locationFromId)->sum('amount'); + } else { + $productStockAmountByLocation = $this->Database->stock()->where('product_id = :1 AND location_id = :2', $productId, $locationFromId)->sum('amount'); + } + if ($amount > $productStockAmountByLocation) + { + throw new \Exception('Amount to be transfered cannot be > current stock amount'); + } + + foreach ($potentialStockEntries as $stockEntry) + { + if ($amount == 0) + { + break; + } + if ($amount >= $stockEntry->amount) + { + $logRow = $this->Database->stock_log()->createRow(array( + 'product_id' => $productId, + 'amount' => $stockEntry->amount * -1, + 'best_before_date' => $stockEntry->best_before_date, + 'purchased_date' => $stockEntry->purchased_date, + 'stock_id' => $stockEntry->stock_id, + 'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_FROM, + 'price' => $stockEntry->price, + 'location_id' => $stockEntry->location_id + )); + $logRow->save(); + //Add the amount into the new location + $this->AddProduct($productId, $stockEntry->amount, $stockEntry->best_before_date, self::TRANSACTION_TYPE_TRANSFER_TO, $stockEntry->purchased_date, $stockEntry->price, $locationId); + + $amount -= $stockEntry->amount; + $stockEntry->delete(); + } else { + $logRow = $this->Database->stock_log()->createRow(array( + 'product_id' => $productId, + 'amount' => $amount * -1, + 'best_before_date' => $stockEntry->best_before_date, + 'purchased_date' => $stockEntry->purchased_date, + 'stock_id' => $stockEntry->stock_id, + 'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_FROM, + 'price' => $stockEntry->price, + 'location_id' => $stockEntry->location_id + )); + $logRow->save(); + + //Add the amount into the new location + $this->AddProduct($productId, $amount, $stockEntry->best_before_date, self::TRANSACTION_TYPE_TRANSFER_TO, $stockEntry->purchased_date, $stockEntry->price, $locationId); + + $stockEntry->update(array( + 'amount' => $stockEntry->amount - $amount + )); + $amount = 0; + } + } + } + public function ConsumeProduct(int $productId, float $amount, bool $spoiled, $transactionType, $specificStockEntryId = 'default', $recipeId = null) { if (!$this->ProductExists($productId)) @@ -772,7 +873,7 @@ class StockService extends BaseService 'undone_timestamp' => date('Y-m-d H:i:s') )); } - elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_CONSUME || ($logRow->transaction_type === self::TRANSACTION_TYPE_INVENTORY_CORRECTION && $logRow->amount < 0)) + else if ($logRow->transaction_type === self::TRANSACTION_TYPE_CONSUME || ($logRow->transaction_type === self::TRANSACTION_TYPE_INVENTORY_CORRECTION && $logRow->amount < 0)) { // Add corresponding amount back to stock if ($stockRow == null) { @@ -799,7 +900,7 @@ class StockService extends BaseService 'undone_timestamp' => date('Y-m-d H:i:s') )); } - elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_PRODUCT_OPENED) + else if ($logRow->transaction_type === self::TRANSACTION_TYPE_PRODUCT_OPENED) { // Remove opened flag from corresponding log entry $stockRows = $this->Database->stock()->where('stock_id = :1 AND amount = :2 AND purchased_date = :3', $logRow->stock_id, $logRow->amount, $logRow->purchased_date)->limit(1); diff --git a/views/layout/default.blade.php b/views/layout/default.blade.php index e42f5dd2..47816c64 100644 --- a/views/layout/default.blade.php +++ b/views/layout/default.blade.php @@ -157,6 +157,14 @@ {{ $__t('Consume') }} + @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) + + + + {{ $__t('Transfer') }} + + + @endif diff --git a/views/stockjournal.blade.php b/views/stockjournal.blade.php index 5704a90d..d5ae0459a 100644 --- a/views/stockjournal.blade.php +++ b/views/stockjournal.blade.php @@ -37,6 +37,7 @@ {{ $__t('Amount') }} {{ $__t('Booking time') }} {{ $__t('Booking type') }} + {{ $__t('Location') }} @@ -65,6 +66,9 @@ {{ $__t($stockLogEntry->transaction_type) }} + + {{ FindObjectInArrayByPropertyValue($locations, 'id', $stockLogEntry->location_id)->name }} + @endforeach diff --git a/views/stockoverview.blade.php b/views/stockoverview.blade.php index ed52c0c5..e08acd8b 100644 --- a/views/stockoverview.blade.php +++ b/views/stockoverview.blade.php @@ -140,6 +140,12 @@ data-product-id="{{ $currentStockEntry->product_id }}"> {{ $__t('Consume') }} + @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) + + {{ $__t('Transfer') }} + + @endif {{ $__t('Inventory') }} diff --git a/views/transfer.blade.php b/views/transfer.blade.php new file mode 100644 index 00000000..3bfdbf81 --- /dev/null +++ b/views/transfer.blade.php @@ -0,0 +1,84 @@ +@extends('layout.default') + +@section('title', $__t('Transfer')) +@section('activeNav', 'transfer') +@section('viewJsName', 'transfer') + +@section('content') + + + @yield('title') + + + + @include('components.productpicker', array( + 'products' => $products, + 'nextInputSelector' => '#location_id_from', + 'disallowAddProductWorkflows' => true + )) + + @php /*@include('components.locationpicker', array( + 'id' => 'location_from', + 'locations' => $locations, + 'isRequired' => true, + 'label' => 'Transfer From Location' + ))*/ @endphp + + + {{ $__t('From location') }} + + + @foreach($locations as $location) + {{ $location->name }} + @endforeach + + {{ $__t('A location is required') }} + + + @include('components.numberpicker', array( + 'id' => 'amount', + 'label' => 'Amount', + 'hintId' => 'amount_qu_unit', + 'min' => 1, + 'value' => 1, + 'invalidFeedback' => $__t('The amount cannot be lower than %s', '1'), + 'additionalHtmlContextHelp' => '' . $__t('Tare weight handling enabled - please weigh the whole container, the amount to be posted will be automatically calculcated') . '' + )) + + + + {{ $__t('Use a specific stock item') }} + {{ $__t('The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"') }} + + + + + + + @php /*@include('components.locationpicker', array( + 'locations' => $locations, + 'isRequired' => true, + 'label' => 'Transfer to Location' + ))*/ @endphp + + + {{ $__t('To location') }} + + + @foreach($locations as $location) + {{ $location->name }} + @endforeach + + {{ $__t('A location is required') }} + + + {{ $__t('OK') }} + + + + + + @include('components.productcard') + + +@stop