Initial Stock detail page

This commit is contained in:
Kurt Riddlesperger 2019-11-04 20:15:59 -06:00
parent 93a774a2a3
commit 38531aa797
13 changed files with 1034 additions and 8 deletions

View File

@ -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();

View File

@ -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;

View File

@ -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",

View File

@ -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

View File

@ -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("<option></option>");
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("<option></option>");
$("#specific_stock_entry").attr("disabled", "");
$("#specific_stock_entry").removeAttr("required");
$("#specific_stock_entry").val("");
$("#location_id").trigger('change');
}
Grocy.FrontendHelpers.ValidateForm("consume-form");

View File

@ -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) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + bookingResponse.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
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) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + bookingResponse.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>');
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('<iframe height="650px" class="embed-responsive" src="' + U("/purchase?embedded&product=") + productId.toString() + '"></iframe>');
});
$(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('<iframe height="650px" class="embed-responsive" src="' + U("/transfer?embedded&product=") + productId.toString() + '&locationId=' + locationId.toString() + '&stockId=' + specificStockEntryId.toString() + '"></iframe>');
});
$(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('<iframe height="650px" class="embed-responsive" src="' + U("/consume?embedded&product=") + productId.toString() + '&locationId=' + locationId.toString() + '&stockId=' + specificStockEntryId.toString() + '"></iframe>');
});
$(document).on("click", ".product-inventory-button", function(e)
{
e.preventDefault();
var productId = $(e.currentTarget).attr("data-product-id");
bootBoxModal('<iframe height="650px" class="embed-responsive" src="' + U("/inventory?embedded&product=") + productId.toString() + '"></iframe>');
});
$(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('<iframe height="650px" class="embed-responsive" src="' + U("/stockedit?embedded&product=") + productId.toString() + '&stockRowId=' + stockRowId.toString() + '"></iframe>');
});
$(document).on("click", ".product-add-to-shopping-list-button", function(e)
{
e.preventDefault();
var productId = $(e.currentTarget).attr("data-product-id");
bootBoxModal('<iframe height="650px" class="embed-responsive" src="' + U("/shoppinglistitem/new?embedded&updateexistingproduct&product=") + productId.toString() + '"></iframe>');
});
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);
}
});

151
public/viewjs/stockedit.js Normal file
View File

@ -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') + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(\'' + result.id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
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);
}
);
};

View File

@ -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("<option></option>");
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);
}
});

View File

@ -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');

View File

@ -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');

197
views/stockdetail.blade.php Normal file
View File

@ -0,0 +1,197 @@
@extends('layout.default')
@section('title', $__t('Stock detail'))
@section('activeNav', 'stockdetail')
@section('viewJsName', 'stockdetail')
@push('pageScripts')
<script src="{{ $U('/node_modules/jquery-ui-dist/jquery-ui.min.js?v=', true) }}{{ $version }}"></script>
<script src="{{ $U('/viewjs/purchase.js?v=', true) }}{{ $version }}"></script>
@endpush
@push('pageStyles')
<style>
.product-name-cell[data-product-has-picture='true'] {
cursor: pointer;
}
</style>
@endpush
@section('content')
<div class="row">
<div class="col">
<h1>@yield('title')</h1>
</div>
<div class="col">
@include('components.productpicker', array('products' => $products,'disallowAddProductWorkflows' => true))
</div>
</div>
<div class="row">
<div class="col">
<table id="stock-detail-table" class="table table-sm table-striped dt-responsive">
<thead>
<tr>
<th class="border-right"></th>
<th class="d-none">product_id</th> <!-- This must be in the first column for searching -->
<th>{{ $__t('Product') }}</th>
<th>{{ $__t('Amount') }}</th>
<th>{{ $__t('Best before date') }}</th>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)<th>{{ $__t('Location') }}</th>@endif
@if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)<th>{{ $__t('Price') }}</th>@endif
<th>{{ $__t('Purchased Date') }}</th>
@include('components.userfields_thead', array(
'userfields' => $userfields
))
</tr>
</thead>
<tbody class="d-none">
@foreach($currentStockDetail as $currentStockEntry)
<tr id="stock-{{ $currentStockEntry->id }}-row" class="@if(GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING && $currentStockEntry->best_before_date < date('Y-m-d 23:59:59', strtotime('-1 days')) && $currentStockEntry->amount > 0) table-danger @elseif(GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING && $currentStockEntry->best_before_date < date('Y-m-d 23:59:59', strtotime("+$nextXDays days")) && $currentStockEntry->amount > 0) table-warning @elseif (FindObjectInArrayByPropertyValue($missingProducts, 'id', $currentStockEntry->product_id) !== null) table-info @endif">
<td class="fit-content border-right">
<a class="btn btn-success btn-sm stock-consume-button @if($currentStockEntry->amount < 1) disabled @endif" href="#" data-toggle="tooltip" data-placement="left" title="{{ $__t('Consume %1$s of %2$s', '1 ' . FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name) }}"
data-product-id="{{ $currentStockEntry->product_id }}"
data-stock-id="{{ $currentStockEntry->stock_id }}"
data-stockrow-id="{{ $currentStockEntry->id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}"
data-product-qu-name="{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name }}"
data-consume-amount="1">
<i class="fas fa-utensils"></i> 1
</a>
<a id="stock-{{ $currentStockEntry->id }}-consume-all-button" class="btn btn-danger btn-sm stock-consume-button @if($currentStockEntry->amount == 0) disabled @endif" href="#" data-toggle="tooltip" data-placement="right" title="{{ $__t('Consume all %s for this stock detail', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name) }}"
data-product-id="{{ $currentStockEntry->product_id }}"
data-stock-id="{{ $currentStockEntry->stock_id }}"
data-stockrow-id="{{ $currentStockEntry->id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}"
data-product-qu-name="{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name }}"
data-consume-amount="{{ $currentStockEntry->amount }}">
<i class="fas fa-utensils"></i> {{ $__t('All') }}
</a>
@if(GROCY_FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING)
<a class="btn btn-success btn-sm product-open-button @if($currentStockEntry->amount < 1 || $currentStockEntry->amount == $currentStockEntry->amount_opened) disabled @endif" href="#" data-toggle="tooltip" data-placement="left" title="{{ $__t('Mark %1$s of %2$s as open', '1 ' . FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name) }}"
data-product-id="{{ $currentStockEntry->product_id }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}"
data-product-qu-name="{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name }}">
<i class="fas fa-box-open"></i> 1
</a>
@endif
<div class="dropdown d-inline-block">
<button class="btn btn-sm btn-light text-secondary" type="button" data-toggle="dropdown">
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="dropdown-menu">
<a class="dropdown-item product-add-to-shopping-list-button" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-shopping-cart"></i> {{ $__t('Add to shopping list') }}
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item product-purchase-button" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-shopping-cart"></i> {{ $__t('Purchase') }}
</a>
<a class="dropdown-item product-consume-custom-amount-button @if($currentStockEntry->amount < 1) disabled @endif" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-stock-id="{{ $currentStockEntry->stock_id }}">
<i class="fas fa-utensils"></i> {{ $__t('Consume') }}
</a>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
<a class="dropdown-item product-transfer-button @if($currentStockEntry->amount < 1) disabled @endif" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-stock-id="{{ $currentStockEntry->stock_id }}">
<i class="fas fa-exchange-alt"></i> {{ $__t('Transfer') }}
</a>
@endif
<a class="dropdown-item product-inventory-button" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-list"></i> {{ $__t('Inventory') }}
</a>
<a class="dropdown-item product-stockedit-button" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-id="{{ $currentStockEntry->id }}">
<i class="fas fa-boxes"></i> {{ $__t('Stock Edit') }}
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item product-name-cell" data-product-id="{{ $currentStockEntry->product_id }}" type="button" href="#">
<i class="fas fa-info"></i> {{ $__t('Show product details') }}
</a>
<a class="dropdown-item" type="button" href="{{ $U('/stockjournal?product=') }}{{ $currentStockEntry->product_id }}">
<i class="fas fa-file-alt"></i> {{ $__t('Stock journal for this product') }}
</a>
<a class="dropdown-item" type="button" href="{{ $U('/product/') }}{{ $currentStockEntry->product_id . '?returnto=%2Fstockdetail' }}">
<i class="fas fa-edit"></i> {{ $__t('Edit product') }}
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item product-consume-button product-consume-button-spoiled @if($currentStockEntry->amount < 1) disabled @endif" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}"
data-product-qu-name="{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name }}"
data-consume-amount="1">
<i class="fas fa-utensils"></i> {{ $__t('Consume %1$s of %2$s as spoiled', '1 ' . FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name) }}
</a>
@if(GROCY_FEATURE_FLAG_RECIPES)
<a class="dropdown-item" type="button" href="{{ $U('/recipes?search=') }}{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}">
<i class="fas fa-cocktail"></i> {{ $__t('Search for recipes containing this product') }}
</a>
@endif
</div>
</div>
</td>
<td class="d-none" data-product-id="{{ $currentStockEntry->product_id }}">
{{ $currentStockEntry->product_id }}
</td>
<td class="product-name-cell cursor-link" data-product-id="{{ $currentStockEntry->product_id }}">
{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}
</td>
<td>
<span id="stock-{{ $currentStockEntry->id }}-amount" class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->amount }}</span> <span id="product-{{ $currentStockEntry->product_id }}-qu-name">{{ $__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) }}</span>
<span id="stock-{{ $currentStockEntry->id }}-opened-amount" class="small font-italic">@if($currentStockEntry->amount_opened > 0){{ $__t('%s opened', $currentStockEntry->amount_opened) }}@endif</span>
@if($currentStockEntry->is_aggregated_amount == 1)
<span class="pl-1 text-secondary">
<i class="fas fa-custom-sigma-sign"></i> <span id="product-{{ $currentStockEntry->product_id }}-amount-aggregated" class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->amount_aggregated }}</span> {{ $__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)<span id="product-{{ $currentStockEntry->product_id }}-opened-amount-aggregated" class="small font-italic">{{ $__t('%s opened', $currentStockEntry->amount_opened_aggregated) }}</span>@endif
</span>
@endif
</td>
<td>
<span id="stock-{{ $currentStockEntry->id }}-best-before-date">{{ $currentStockEntry->best_before_date }}</span>
<time id="stock-{{ $currentStockEntry->id }}-best-before-date-timeago" class="timeago timeago-contextual" datetime="{{ $currentStockEntry->best_before_date }} 23:59:59"></time>
</td>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
<td id="stock-{{ $currentStockEntry->id }}-location" class="location-name-cell cursor-link" data-location-id="{{ $currentStockEntry->location_id }}">
{{ FindObjectInArrayByPropertyValue($locations, 'id', $currentStockEntry->location_id)->name }}
</td>
@endif
@if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)
<td id="stock-{{ $currentStockEntry->id }}-price" class="price-name-cell cursor-link" data-price-id="{{ $currentStockEntry->price }}">
{{ $currentStockEntry->price }}
</td>
@endif
<td>
<span id="stock-{{ $currentStockEntry->id }}-purchased-date">{{ $currentStockEntry->purchased_date }}</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="modal fade" id="stockdetail-productcard-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content text-center">
<div class="modal-body">
@include('components.productcard')
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ $__t('Close') }}</button>
</div>
</div>
</div>
</div>
@stop

81
views/stockedit.blade.php Normal file
View File

@ -0,0 +1,81 @@
@extends('layout.default')
@section('title', $__t('Stock Edit'))
@section('activeNav', 'stockedit')
@section('viewJsName', 'stockedit')
@section('content')
<div class="row">
<div class="col-xs-12 col-md-6 col-xl-4 pb-3">
<h1>@yield('title')</h1>
<form id="stockedit-form" novalidate>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
@include('components.locationpicker', array(
'locations' => $locations
))
@else
<input type="hidden" name="location_id" id="location_id" value="1">
@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' => '<div id="stockedit-change-info" class="form-text text-muted small d-none"></div>',
'additionalHtmlContextHelp' => '<div id="tare-weight-handling-info" class="text-small text-info font-italic d-none">' . $__t('Tare weight handling enabled - please weigh the whole container, the amount to be posted will be automatically calculcated') . '</div>'
))
@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
<input type="hidden" name="price" id="price" value="0">
@endif
<button id="save-stockedit-button" class="btn btn-success">{{ $__t('OK') }}</button>
</form>
</div>
<div class="col-xs-12 col-md-6 col-xl-4 hide-when-embedded">
@include('components.productcard')
</div>
</div>
@stop

View File

@ -154,6 +154,10 @@
<a class="dropdown-item product-name-cell" data-product-id="{{ $currentStockEntry->product_id }}" type="button" href="#">
<i class="fas fa-info"></i> {{ $__t('Show product details') }}
</a>
<a class="dropdown-item" type="button" href="{{ $U('/stockdetail?product=') }}{{ $currentStockEntry->product_id }}"
data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-boxes"></i> {{ $__t('Show stock details') }}
</a>
<a class="dropdown-item" type="button" href="{{ $U('/stockjournal?product=') }}{{ $currentStockEntry->product_id }}">
<i class="fas fa-file-alt"></i> {{ $__t('Stock journal for this product') }}
</a>