From 38531aa797f6684bff715d3acc13ef47466cf201 Mon Sep 17 00:00:00 2001 From: Kurt Riddlesperger Date: Mon, 4 Nov 2019 20:15:59 -0600 Subject: [PATCH] Initial Stock detail page --- controllers/StockApiController.php | 48 +++++ controllers/StockController.php | 27 +++ grocy.openapi.json | 73 +++++++ migrations/0095.sql | 3 + public/viewjs/consume.js | 39 +++- public/viewjs/stockdetail.js | 293 +++++++++++++++++++++++++++++ public/viewjs/stockedit.js | 151 +++++++++++++++ public/viewjs/transfer.js | 33 +++- routes.php | 3 + services/StockService.php | 90 ++++++++- views/stockdetail.blade.php | 197 +++++++++++++++++++ views/stockedit.blade.php | 81 ++++++++ views/stockoverview.blade.php | 4 + 13 files changed, 1034 insertions(+), 8 deletions(-) create mode 100644 public/viewjs/stockdetail.js create mode 100644 public/viewjs/stockedit.js create mode 100644 views/stockdetail.blade.php create mode 100644 views/stockedit.blade.php diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php index e1982edc..c9cc5538 100644 --- a/controllers/StockApiController.php +++ b/controllers/StockApiController.php @@ -113,6 +113,54 @@ class StockApiController extends BaseApiController } } + public function EditStock(\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('stock_row_id', $requestBody)) + { + throw new \Exception('A stock row id is required'); + } + + if (!array_key_exists('amount', $requestBody)) + { + throw new \Exception('An amount is required'); + } + + $bestBeforeDate = null; + if (array_key_exists('best_before_date', $requestBody) && IsIsoDate($requestBody['best_before_date'])) + { + $bestBeforeDate = $requestBody['best_before_date']; + } + + $price = null; + if (array_key_exists('price', $requestBody) && is_numeric($requestBody['price'])) + { + $price = $requestBody['price']; + } + + $locationId = null; + if (array_key_exists('location_id', $requestBody) && is_numeric($requestBody['location_id'])) + { + $locationId = $requestBody['location_id']; + } + + $bookingId = $this->StockService->EditStock($requestBody['stock_row_id'], $requestBody['amount'], $bestBeforeDate, $locationId, $price); + return $this->ApiResponse($this->Database->stock_log($bookingId)); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + public function TransferProduct(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { $requestBody = $request->getParsedBody(); diff --git a/controllers/StockController.php b/controllers/StockController.php index 470f3eff..7436d02d 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -38,6 +38,25 @@ class StockController extends BaseController ]); } + public function Detail(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + $usersService = new UsersService(); + $nextXDays = $usersService->GetUserSettings(GROCY_USER_ID)['stock_expring_soon_days']; + + return $this->AppContainer->view->render($response, 'stockdetail', [ + 'products' => $this->Database->products()->orderBy('name'), + 'quantityunits' => $this->Database->quantity_units()->orderBy('name'), + 'locations' => $this->Database->locations()->orderBy('name'), + 'currentStockDetail' => $this->Database->stock()->orderBy('product_id'), + 'currentStockLocations' => $this->StockService->GetCurrentStockLocations(), + 'missingProducts' => $this->StockService->GetMissingProducts(), + 'nextXDays' => $nextXDays, + 'productGroups' => $this->Database->product_groups()->orderBy('name'), + 'userfields' => $this->UserfieldsService->GetFields('products'), + 'userfieldValues' => $this->UserfieldsService->GetAllValues('products') + ]); + } + public function Purchase(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { return $this->AppContainer->view->render($response, 'purchase', [ @@ -72,6 +91,14 @@ class StockController extends BaseController ]); } + public function StockEdit(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->AppContainer->view->render($response, 'stockedit', [ + 'products' => $this->Database->products()->orderBy('name'), + 'locations' => $this->Database->locations()->orderBy('name') + ]); + } + public function ShoppingList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { $listId = 1; diff --git a/grocy.openapi.json b/grocy.openapi.json index c3fcd69b..4fd92a4d 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -1050,6 +1050,78 @@ } } } + }, + "put": { + "summary": "Edits the stock entry", + "tags": [ + "Stock" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stock_row_id": { + "type": "number", + "format": "number", + "description": "The Stock Row Id" + }, + "amount": { + "type": "number", + "format": "number", + "description": "The amount to add - 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" + }, + "best_before_date": { + "type": "string", + "format": "date", + "description": "The best before date of the product to add, when omitted, the current date is used" + }, + "location_id": { + "type": "number", + "format": "integer", + "description": "If omitted, the default location of the product is used" + }, + "price": { + "type": "number", + "format": "number", + "description": "The price per purchase quantity unit in configured currency" + } + }, + "example": { + "stock_row_id": 2, + "amount": 1, + "best_before_date": "2019-01-19", + "location_id": 2, + "price": "1.99" + } + } + } + } + }, + "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)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } } }, "/stock/volatile": { @@ -3131,6 +3203,7 @@ "quantity_unit_conversions", "shopping_list", "shopping_lists", + "stock", "recipes", "recipes_pos", "recipes_nestings", diff --git a/migrations/0095.sql b/migrations/0095.sql index b67063ba..dfd01779 100644 --- a/migrations/0095.sql +++ b/migrations/0095.sql @@ -20,6 +20,9 @@ ADD correlation_id TEXT; ALTER TABLE stock_log ADD transaction_id TEXT; +ALTER TABLE stock_log +ADD stock_row_id INTEGER; + DROP VIEW stock_current_locations; CREATE VIEW stock_current_locations AS diff --git a/public/viewjs/consume.js b/public/viewjs/consume.js index 1aef81dc..2b37f23c 100644 --- a/public/viewjs/consume.js +++ b/public/viewjs/consume.js @@ -1,4 +1,24 @@ -$('#save-consume-button').on('click', function(e) +$(document).ready(function() { + + if (GetUriParam("embedded") !== undefined) + { + var locationId = GetUriParam('locationId'); + + if (typeof locationId === 'undefined') + { + Grocy.Components.ProductPicker.GetPicker().trigger('change'); + Grocy.Components.ProductPicker.GetInputElement().focus(); + } else { + + $("#location_id").val(locationId); + $("#location_id").trigger('change'); + $("#use_specific_stock_entry").click(); + $("#use_specific_stock_entry").trigger('change'); + } + } +}); + +$('#save-consume-button').on('click', function(e) { e.preventDefault(); @@ -184,12 +204,19 @@ $("#location_id").on('change', function(e) { var locationId = $(e.target).val(); var sumValue = 0; + var stockId = null; + $("#specific_stock_entry").find("option").remove().end().append(""); if ($("#use_specific_stock_entry").is(":checked")) { $("#use_specific_stock_entry").click(); } + if (GetUriParam("embedded") !== undefined) + { + stockId = GetUriParam('stockId'); + } + if (locationId) { Grocy.Api.Get("stock/products/" + Grocy.Components.ProductPicker.GetValue() + '/entries', @@ -211,6 +238,11 @@ $("#location_id").on('change', function(e) text: __t("Amount: %1$s; Expires on %2$s; Bought on %3$s", stockEntry.amount, moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD")) + "; " + openTxt })); sumValue = sumValue + parseFloat(stockEntry.amount); + + if (stockEntry.stock_id == stockId) + { + $("#specific_stock_entry").val(stockId); + } } }); $("#amount").attr("max", sumValue); @@ -345,8 +377,6 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e) }); $('#amount').val(Grocy.UserSettings.stock_default_consume_amount); -Grocy.Components.ProductPicker.GetPicker().trigger('change'); -Grocy.Components.ProductPicker.GetInputElement().focus(); Grocy.FrontendHelpers.ValidateForm('consume-form'); $('#amount').on('focus', function(e) @@ -430,9 +460,10 @@ $("#use_specific_stock_entry").on("change", function() } else { - $("#specific_stock_entry").find("option").remove().end().append(""); $("#specific_stock_entry").attr("disabled", ""); $("#specific_stock_entry").removeAttr("required"); + $("#specific_stock_entry").val(""); + $("#location_id").trigger('change'); } Grocy.FrontendHelpers.ValidateForm("consume-form"); diff --git a/public/viewjs/stockdetail.js b/public/viewjs/stockdetail.js new file mode 100644 index 00000000..ab871201 --- /dev/null +++ b/public/viewjs/stockdetail.js @@ -0,0 +1,293 @@ +var stockDetailTable = $('#stock-detail-table').DataTable({ + 'order': [[2, 'asc']], + 'columnDefs': [ + { 'orderable': false, 'targets': 0 }, + ], +}); +$('#stock-detail-table tbody').removeClass("d-none"); + +function bootBoxModal(message) { + bootbox.dialog({ + message: message, + size: 'large', + backdrop: true, + closeButton: false, + buttons: { + cancel: { + label: __t('Cancel'), + className: 'btn-secondary responsive-button', + callback: function() + { + bootbox.hideAll(); + } + } + } + }); +} + + + +$.fn.dataTable.ext.search.push( + function( settings, data, dataIndex ) { + var productId = Grocy.Components.ProductPicker.GetValue(); + + if ( ( isNaN( productId ) || + productId == "" || + //assume productId is in the first column + productId == data[1] ) ) + { + return true; + } + return false; + } +); + +$(document).ready(function() { + Grocy.Components.ProductPicker.GetPicker().trigger('change'); +} ); + +Grocy.Components.ProductPicker.GetPicker().on('change', function(e) +{ + stockDetailTable.draw(); +}); + +$(document).on('click', '.stock-consume-button', function(e) +{ + e.preventDefault(); + + // Remove the focus from the current button + // to prevent that the tooltip stays until clicked anywhere else + document.activeElement.blur(); + + Grocy.FrontendHelpers.BeginUiBusy(); + + var productId = $(e.currentTarget).attr('data-product-id'); + var locationId = $(e.currentTarget).attr('data-location-id'); + var specificStockEntryId = $(e.currentTarget).attr('data-stock-id'); + var stockRowId = $(e.currentTarget).attr('data-stockrow-id'); + var consumeAmount = $(e.currentTarget).attr('data-consume-amount'); + + var wasSpoiled = $(e.currentTarget).hasClass("product-consume-button-spoiled"); + + Grocy.Api.Post('stock/products/' + productId + '/consume', { 'amount': consumeAmount, 'spoiled': wasSpoiled, 'location_id': locationId, 'stock_entry_id': specificStockEntryId}, + function(bookingResponse) + { + Grocy.Api.Get('stock/products/' + productId, + function(result) + { + var toastMessage = __t('Removed %1$s of %2$s from stock', consumeAmount.toString() + " " + __n(consumeAmount, result.quantity_unit_stock.name, result.quantity_unit_stock.name_plural), result.product.name) + '
' + __t("Undo") + ''; + if (wasSpoiled) + { + toastMessage += " (" + __t("Spoiled") + ")"; + } + + Grocy.FrontendHelpers.EndUiBusy(); + toastr.success(toastMessage); + RefreshStockDetailRow(stockRowId); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy(); + console.error(xhr); + } + ); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy(); + console.error(xhr); + } + ); +}); + +$(document).on('click', '.product-open-button', function(e) +{ + e.preventDefault(); + + // Remove the focus from the current button + // to prevent that the tooltip stays until clicked anywhere else + document.activeElement.blur(); + + Grocy.FrontendHelpers.BeginUiBusy(); + + var productId = $(e.currentTarget).attr('data-product-id'); + var productName = $(e.currentTarget).attr('data-product-name'); + var productQuName = $(e.currentTarget).attr('data-product-qu-name'); + var button = $(e.currentTarget); + + Grocy.Api.Post('stock/products/' + productId + '/open', { 'amount': 1 }, + function(bookingResponse) + { + Grocy.Api.Get('stock/products/' + productId, + function(result) + { + if (result.stock_amount == result.stock_amount_opened) + { + button.addClass("disabled"); + } + + Grocy.FrontendHelpers.EndUiBusy(); + toastr.success(__t('Marked %1$s of %2$s as opened', 1 + " " + productQuName, productName) + '
' + __t("Undo") + ''); + RefreshProductRow(productId); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy(); + console.error(xhr); + } + ); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy(); + console.error(xhr); + } + ); +}); + +$(document).on("click", ".stock-name-cell", function(e) +{ + Grocy.Components.ProductCard.Refresh($(e.currentTarget).attr("data-stock-id")); + $("#stockdetail-productcard-modal").modal("show"); +}); + +$(document).on("click", ".product-purchase-button", function(e) +{ + e.preventDefault(); + + var productId = $(e.currentTarget).attr("data-product-id"); + + bootBoxModal(''); +}); + +$(document).on("click", ".product-transfer-button", function(e) +{ + + e.preventDefault(); + + var productId = $(e.currentTarget).attr("data-product-id"); + var locationId = $(e.currentTarget).attr('data-location-id'); + var specificStockEntryId = $(e.currentTarget).attr('data-stock-id'); + bootBoxModal(''); + +}); + +$(document).on("click", ".product-consume-custom-amount-button", function(e) +{ + e.preventDefault(); + + var productId = $(e.currentTarget).attr("data-product-id"); + var locationId = $(e.currentTarget).attr('data-location-id'); + var specificStockEntryId = $(e.currentTarget).attr('data-stock-id'); + + bootBoxModal(''); + +}); + +$(document).on("click", ".product-inventory-button", function(e) +{ + e.preventDefault(); + + var productId = $(e.currentTarget).attr("data-product-id"); + + bootBoxModal(''); +}); + +$(document).on("click", ".product-stockedit-button", function(e) +{ + e.preventDefault(); + + var productId = $(e.currentTarget).attr("data-product-id"); + var stockRowId = $(e.currentTarget).attr("data-id"); + + bootBoxModal(''); +}); + +$(document).on("click", ".product-add-to-shopping-list-button", function(e) +{ + e.preventDefault(); + + var productId = $(e.currentTarget).attr("data-product-id"); + + bootBoxModal(''); +}); + +function RefreshStockDetailRow(stockRowId) +{ + Grocy.Api.Get("objects/stock/" + stockRowId, + function(result) + { + var stockRow = $('#stock-' + stockRowId + '-row'); + var now = moment(); + + stockRow.removeClass("table-warning"); + stockRow.removeClass("table-danger"); + stockRow.removeClass("table-info"); + stockRow.removeClass("d-none"); + stockRow.removeAttr("style"); + + if (result == null || result.amount == 0) + { + stockRow.fadeOut(500, function() + { + //$(this).tooltip("hide"); + $(this).addClass("d-none"); + }); + } + else + { + $('#stock-' + stockRowId + '-amount').parent().effect('highlight', { }, 500); + $('#stock-' + stockRowId + '-amount').fadeOut(500, function () + { + $(this).text(result.amount).fadeIn(500); + }); + + $('#stock-' + stockRowId + '-best-before-date').parent().effect('highlight', { }, 500); + $('#stock-' + stockRowId + '-best-before-date').fadeOut(500, function() + { + $(this).text(result.best_before_date).fadeIn(500); + }); + + $('#stock-' + stockRowId + '-location').parent().effect('highlight', { }, 500); + $('#stock-' + stockRowId + '-location').fadeOut(500, function() + { + //TODO grab location name instead of id + $(this).text(result.location_id).fadeIn(500); + }); + + $('#stock-' + stockRowId + '-price').parent().effect('highlight', { }, 500); + $('#stock-' + stockRowId + '-price').fadeOut(500, function() + { + $(this).text(result.price).fadeIn(500); + }); + + $('#stock-' + stockRowId + '-purchased-date').parent().effect('highlight', { }, 500); + $('#stock-' + stockRowId + '-purchased-date').fadeOut(500, function() + { + $(this).text(result.purchased_date).fadeIn(500); + }); + } + + setTimeout(function() + { + RefreshContextualTimeago(); + RefreshLocaleNumberDisplay(); + }, 600); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy(); + console.error(xhr); + } + ); +} + +$(window).on("message", function(e) +{ + var data = e.originalEvent.data; + + if (data.Message === "StockDetailChanged") + { + RefreshStockDetailRow(data.Payload); + } +}); diff --git a/public/viewjs/stockedit.js b/public/viewjs/stockedit.js new file mode 100644 index 00000000..8858b499 --- /dev/null +++ b/public/viewjs/stockedit.js @@ -0,0 +1,151 @@ +$(document).ready(function() { + var stockRowId = GetUriParam('stockRowId'); + Grocy.Api.Get("objects/stock/" + stockRowId, + function(stockEntry) + { + Grocy.Components.LocationPicker.SetId(stockEntry.location_id); + $('#amount').val(stockEntry.amount); + $('#price').val(stockEntry.price); + Grocy.Components.DateTimePicker.SetValue(stockEntry.best_before_date); + + Grocy.Api.Get('stock/products/' + stockEntry.product_id, + function(productDetails) + { + $('#amount_qu_unit').text(productDetails.quantity_unit_stock.name); + + 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 cannot be lower than %1$s', 0.01.toLocaleString())); + } + else + { + $("#amount").attr("min", "1"); + $("#amount").attr("step", "1"); + $("#amount").parent().find(".invalid-feedback").text(__t('The amount cannot be lower than %1$s', '1')); + } + + if (productDetails.product.enable_tare_weight_handling == 1) + { + $("#amount").attr("min", productDetails.product.tare_weight); + $("#amount").parent().find(".invalid-feedback").text(__t('The amount cannot be lower than %1$s', parseFloat(productDetails.product.tare_weight).toLocaleString({ minimumFractionDigits: 0, maximumFractionDigits: 2 }))); + $("#tare-weight-handling-info").removeClass("d-none"); + } + else + { + $("#tare-weight-handling-info").addClass("d-none"); + } + + }, + function(xhr) + { + console.error(xhr); + } + ); + }, + function(xhr) + { + console.error(xhr); + } + ); +} ); + +$('#save-stockedit-button').on('click', function(e) +{ + e.preventDefault(); + + var jsonForm = $('#stockedit-form').serializeJSON(); + Grocy.FrontendHelpers.BeginUiBusy("stockedit-form"); + + if (!jsonForm.price.toString().isEmpty()) + { + price = parseFloat(jsonForm.price).toFixed(2); + } + + var jsonData = { }; + jsonData.amount = jsonForm.amount; + jsonData.best_before_date = Grocy.Components.DateTimePicker.GetValue(); + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) + { + jsonData.location_id = Grocy.Components.LocationPicker.GetValue(); + } + else + { + jsonData.location_id = 1; + } + jsonData.price = price; + + var bookingResponse = null; + + var stockRowId = GetUriParam('stockRowId'); + jsonData.stock_row_id = stockRowId; + + Grocy.Api.Put("stock", jsonData, + function(result) + { + var successMessage = __t('Updated Stock detail') + '
' + __t("Undo") + ''; + + window.parent.postMessage(WindowMessageBag("StockDetailChanged", stockRowId), Grocy.BaseUrl); + window.parent.postMessage(WindowMessageBag("ShowSuccessMessage", successMessage), Grocy.BaseUrl); + window.parent.postMessage(WindowMessageBag("Ready"), Grocy.BaseUrl); + window.parent.postMessage(WindowMessageBag("CloseAllModals"), Grocy.BaseUrl); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("stockedit-form"); + console.error(xhr); + } + ); +}); + +Grocy.FrontendHelpers.ValidateForm('stockedit-form'); + +$('#stockedit-form input').keyup(function (event) +{ + Grocy.FrontendHelpers.ValidateForm('stockedit-form'); +}); + +$('#stockedit-form input').keydown(function(event) +{ + if (event.keyCode === 13) //Enter + { + event.preventDefault(); + + if (document.getElementById('stockedit-form').checkValidity() === false) //There is at least one validation error + { + return false; + } + else + { + $('#save-stockedit-button').click(); + } + } +}); + +if (Grocy.Components.DateTimePicker) +{ + Grocy.Components.DateTimePicker.GetInputElement().on('change', function(e) + { + Grocy.FrontendHelpers.ValidateForm('stockedit-form'); + }); + + Grocy.Components.DateTimePicker.GetInputElement().on('keypress', function(e) + { + Grocy.FrontendHelpers.ValidateForm('stockedit-form'); + }); +} + +function UndoStockBooking(bookingId) +{ + Grocy.Api.Post('stock/bookings/' + bookingId.toString() + '/undo', { }, + function(result) + { + toastr.success(__t("Booking successfully undone")); + }, + function(xhr) + { + console.error(xhr); + } + ); +}; diff --git a/public/viewjs/transfer.js b/public/viewjs/transfer.js index 011336be..9bfc8116 100644 --- a/public/viewjs/transfer.js +++ b/public/viewjs/transfer.js @@ -1,4 +1,23 @@ -$('#save-transfer-button').on('click', function(e) +$(document).ready(function() { + if (GetUriParam("embedded") !== undefined) + { + var locationId = GetUriParam('locationId'); + + if (typeof locationId === 'undefined') + { + Grocy.Components.ProductPicker.GetPicker().trigger('change'); + Grocy.Components.ProductPicker.GetInputElement().focus(); + } else { + + $("#location_id_from").val(locationId); + $("#location_id_from").trigger('change'); + $("#use_specific_stock_entry").click(); + $("#use_specific_stock_entry").trigger('change'); + } + } +}); + +$('#save-transfer-button').on('click', function(e) { e.preventDefault(); @@ -219,20 +238,24 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e) }); $('#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; + var stockId = null; if (locationId == $("#location_id_to").val()) { $("#location_id_to").val(""); } + if (GetUriParam("embedded") !== undefined) + { + stockId = GetUriParam('stockId'); + } + $("#specific_stock_entry").find("option").remove().end().append(""); if ($("#use_specific_stock_entry").is(":checked")) { @@ -259,6 +282,10 @@ $("#location_id_from").on('change', function(e) amount: stockEntry.amount, text: __t("Amount: %1$s; Expires on %2$s; Bought on %3$s", stockEntry.amount, moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD")) + "; " + openTxt })); + if (stockEntry.stock_id == stockId) + { + $("#specific_stock_entry").val(stockId); + } sumValue = sumValue + parseFloat(stockEntry.amount); } }); diff --git a/routes.php b/routes.php index 0fea71f8..9f4ae346 100644 --- a/routes.php +++ b/routes.php @@ -33,10 +33,12 @@ $app->group('', function() if (GROCY_FEATURE_FLAG_STOCK) { $this->get('/stockoverview', '\Grocy\Controllers\StockController:Overview'); + $this->get('/stockdetail', '\Grocy\Controllers\StockController:Detail'); $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('/stockedit', '\Grocy\Controllers\StockController:StockEdit'); $this->get('/products', '\Grocy\Controllers\StockController:ProductsList'); $this->get('/product/{productId}', '\Grocy\Controllers\StockController:ProductEditForm'); $this->get('/stocksettings', '\Grocy\Controllers\StockController:StockSettings'); @@ -159,6 +161,7 @@ $app->group('/api', function() if (GROCY_FEATURE_FLAG_STOCK) { $this->get('/stock', '\Grocy\Controllers\StockApiController:CurrentStock'); + $this->put('/stock', '\Grocy\Controllers\StockApiController:EditStock'); $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'); diff --git a/services/StockService.php b/services/StockService.php index a22db698..08d7a6c0 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -9,6 +9,8 @@ class StockService extends BaseService const TRANSACTION_TYPE_TRANSFER_FROM = 'transfer_from'; const TRANSACTION_TYPE_TRANSFER_TO = 'transfer_to'; const TRANSACTION_TYPE_INVENTORY_CORRECTION = 'inventory-correction'; + const TRANSACTION_TYPE_STOCK_EDIT_NEW = 'stock-edit-new'; + const TRANSACTION_TYPE_STOCK_EDIT_OLD = 'stock-edit-old'; const TRANSACTION_TYPE_PRODUCT_OPENED = 'product-opened'; public function GetCurrentStock($includeNotInStockButMissingProducts = false) @@ -547,6 +549,61 @@ class StockService extends BaseService return $this->Database->lastInsertId(); } + public function EditStock(int $stockRowId, int $amount, $bestBeforeDate, $locationId, $price) + { + + $stockRow = $this->Database->stock()->where('id = :1', $stockRowId)->fetch(); + + if ($stockRow === null) + { + throw new \Exception('Stock does not exist'); + } + + $correlationId = uniqid(); + $transactionId = uniqid(); + $logOldRowForStockUpdate = $this->Database->stock_log()->createRow(array( + 'product_id' => $stockRow->product_id, + 'amount' => $stockRow->amount, + 'best_before_date' => $stockRow->best_before_date, + 'purchased_date' => $stockRow->purchased_date, + 'stock_id' => $stockRow->stock_id, + 'transaction_type' => self::TRANSACTION_TYPE_STOCK_EDIT_OLD, + 'price' => $stockRow->price, + 'opened_date' => $stockRow->opened_date, + 'location_id' => $stockRow->location_id, + 'correlation_id' => $correlationId, + 'transaction_id' => $transactionId, + 'stock_row_id' => $stockRow->id + )); + $logOldRowForStockUpdate->save(); + + $stockRow->update(array( + 'amount' => $amount, + 'price' => $price, + 'best_before_date' => $bestBeforeDate, + 'location_id' => $locationId + )); + + $logNewRowForStockUpdate = $this->Database->stock_log()->createRow(array( + 'product_id' => $stockRow->product_id, + 'amount' => $amount, + 'best_before_date' => $bestBeforeDate, + 'purchased_date' => $stockRow->purchased_date, + 'stock_id' => $stockRow->stock_id, + 'transaction_type' => self::TRANSACTION_TYPE_STOCK_EDIT_NEW, + 'price' => $price, + 'opened_date' => $stockRow->opened_date, + 'location_id' => $locationId, + 'correlation_id' => $correlationId, + 'transaction_id' => $transactionId, + 'stock_row_id' => $stockRow->id + )); + $logNewRowForStockUpdate->save(); + + $returnValue = $this->Database->lastInsertId(); + + return $returnValue; + } public function InventoryProduct(int $productId, int $newAmount, $bestBeforeDate, $locationId = null, $price = null) { if (!$this->ProductExists($productId)) @@ -875,7 +932,7 @@ class StockService extends BaseService } } - $hasSubsequentBookings = $this->Database->stock_log()->where('stock_id = :1 AND id != :2 AND correlation_id != :3 AND id > :2', $logRow->stock_id, $logRow->id, $logRow->correlation_id)->count() > 0; + $hasSubsequentBookings = $this->Database->stock_log()->where('stock_id = :1 AND id != :2 AND (correlation_id is not null OR correlation_id != :3) AND id > :2 AND undone = 0', $logRow->stock_id, $logRow->id, $logRow->correlation_id)->count() > 0; if ($hasSubsequentBookings) { throw new \Exception('Booking has subsequent dependent bookings, undo not possible'); @@ -928,6 +985,37 @@ class StockService extends BaseService 'undone_timestamp' => date('Y-m-d H:i:s') )); } + elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_STOCK_EDIT_NEW) + { + // Update log entry, no action needed + $logRow->update(array( + 'undone' => 1, + 'undone_timestamp' => date('Y-m-d H:i:s') + )); + } + elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_STOCK_EDIT_OLD) + { + // Make sure there is a stock row still + $stockRow = $this->Database->stock()->where('id = :1', $logRow->stock_row_id)->fetch(); + if ($stockRow == null) + { + throw new \Exception('Booking does not exist or was already undone'); + } + + $stockRow->update(array( + 'amount' => $logRow->amount, + 'best_before_date' => $logRow->best_before_date, + 'purchased_date' => $logRow->purchased_date, + 'price' => $logRow->price, + 'location_id' => $logRow->location_id + )); + + // Update log entry + $logRow->update(array( + 'undone' => 1, + 'undone_timestamp' => date('Y-m-d H:i:s') + )); + } else { throw new \Exception('This booking cannot be undone'); diff --git a/views/stockdetail.blade.php b/views/stockdetail.blade.php new file mode 100644 index 00000000..cca2b010 --- /dev/null +++ b/views/stockdetail.blade.php @@ -0,0 +1,197 @@ +@extends('layout.default') + +@section('title', $__t('Stock detail')) +@section('activeNav', 'stockdetail') +@section('viewJsName', 'stockdetail') + +@push('pageScripts') + + +@endpush + +@push('pageStyles') + +@endpush + +@section('content') +
+
+

@yield('title')

+
+
+ @include('components.productpicker', array('products' => $products,'disallowAddProductWorkflows' => true)) +
+
+ +
+
+ + + + + + + + + @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)@endif + @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)@endif + + + @include('components.userfields_thead', array( + 'userfields' => $userfields + )) + + + + @foreach($currentStockDetail as $currentStockEntry) + amount > 0) table-warning @elseif (FindObjectInArrayByPropertyValue($missingProducts, 'id', $currentStockEntry->product_id) !== null) table-info @endif"> + + + + + + @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) + + @endif + @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) + + @endif + + + @endforeach + +
product_id {{ $__t('Product') }}{{ $__t('Amount') }}{{ $__t('Best before date') }}{{ $__t('Location') }}{{ $__t('Price') }}{{ $__t('Purchased Date') }}
+ + 1 + + + {{ $__t('All') }} + + @if(GROCY_FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING) + + 1 + + @endif + + + {{ $currentStockEntry->product_id }} + + {{ $currentStockEntry->amount }} {{ $__n($currentStockEntry->amount, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name_plural) }} + @if($currentStockEntry->amount_opened > 0){{ $__t('%s opened', $currentStockEntry->amount_opened) }}@endif + @if($currentStockEntry->is_aggregated_amount == 1) + + {{ $currentStockEntry->amount_aggregated }} {{ $__n($currentStockEntry->amount_aggregated, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name_plural) }} + @if($currentStockEntry->amount_opened_aggregated > 0){{ $__t('%s opened', $currentStockEntry->amount_opened_aggregated) }}@endif + + @endif + + {{ $currentStockEntry->best_before_date }} + + + {{ $currentStockEntry->purchased_date }} +
+
+
+ + +@stop diff --git a/views/stockedit.blade.php b/views/stockedit.blade.php new file mode 100644 index 00000000..9f5d74d9 --- /dev/null +++ b/views/stockedit.blade.php @@ -0,0 +1,81 @@ +@extends('layout.default') + +@section('title', $__t('Stock Edit')) +@section('activeNav', 'stockedit') +@section('viewJsName', 'stockedit') + +@section('content') +
+
+

@yield('title')

+ +
+ + @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) + @include('components.locationpicker', array( + 'locations' => $locations + )) + @else + + @endif + + @include('components.numberpicker', array( + 'id' => 'amount', + 'label' => 'Amount', + 'hintId' => 'amount_qu_unit', + 'invalidFeedback' => $__t('The amount cannot be lower than %s', '0'), + 'additionalAttributes' => 'data-not-equal="-1"', + 'additionalHtmlElements' => '
', + 'additionalHtmlContextHelp' => '
' . $__t('Tare weight handling enabled - please weigh the whole container, the amount to be posted will be automatically calculcated') . '
' + )) + + @php + $additionalGroupCssClasses = ''; + if (!GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING) + { + $additionalGroupCssClasses = 'd-none'; + } + @endphp + @include('components.datetimepicker', array( + 'id' => 'best_before_date', + 'label' => 'Best before', + 'format' => 'YYYY-MM-DD', + 'initWithNow' => false, + 'limitEndToNow' => false, + 'limitStartToNow' => false, + 'invalidFeedback' => $__t('A best before date is required'), + 'nextInputSelector' => '#best_before_date', + 'additionalGroupCssClasses' => 'date-only-datetimepicker', + 'shortcutValue' => '2999-12-31', + 'shortcutLabel' => 'Never expires', + 'earlierThanInfoLimit' => date('Y-m-d'), + 'earlierThanInfoText' => $__t('The given date is earlier than today, are you sure?'), + 'additionalGroupCssClasses' => $additionalGroupCssClasses + )) + @php $additionalGroupCssClasses = ''; @endphp + + @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) + @include('components.numberpicker', array( + 'id' => 'price', + 'label' => 'Price', + 'min' => 0, + 'step' => 0.01, + 'value' => '', + 'hint' => $__t('in %s per purchase quantity unit', GROCY_CURRENCY), + 'invalidFeedback' => $__t('The price cannot be lower than %s', '0'), + 'isRequired' => false + )) + @else + + @endif + + + +
+
+ +
+ @include('components.productcard') +
+
+@stop diff --git a/views/stockoverview.blade.php b/views/stockoverview.blade.php index defdc5c1..18a8f60a 100644 --- a/views/stockoverview.blade.php +++ b/views/stockoverview.blade.php @@ -154,6 +154,10 @@ {{ $__t('Show product details') }} + + {{ $__t('Show stock details') }} + {{ $__t('Stock journal for this product') }}