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')
+
| + | product_id | +{{ $__t('Product') }} | +{{ $__t('Amount') }} | +{{ $__t('Best before date') }} | + @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING){{ $__t('Location') }} | @endif + @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING){{ $__t('Price') }} | @endif +{{ $__t('Purchased Date') }} | + + @include('components.userfields_thead', array( + 'userfields' => $userfields + )) +
|---|---|---|---|---|---|---|---|
|
+
+ 1
+
+
+ {{ $__t('All') }}
+
+ @if(GROCY_FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING)
+
+ 1
+
+ @endif
+
+
+
+
+ |
+ + {{ $currentStockEntry->product_id }} + | ++ {{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }} + | ++ {{ $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 }} + + | + @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) ++ {{ FindObjectInArrayByPropertyValue($locations, 'id', $currentStockEntry->location_id)->name }} + | + @endif + @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) ++ {{ $currentStockEntry->price }} + | + @endif ++ {{ $currentStockEntry->purchased_date }} + | +