mirror of
https://github.com/grocy/grocy.git
synced 2026-04-05 20:36:15 +02:00
Transfer Products
This commit is contained in:
parent
ec194a9dac
commit
18c607fe1d
|
|
@ -113,6 +113,60 @@ class StockApiController extends BaseApiController
|
|||
}
|
||||
}
|
||||
|
||||
public function TransferProduct(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
|
||||
{
|
||||
$requestBody = $request->getParsedBody();
|
||||
|
||||
try
|
||||
{
|
||||
if ($requestBody === null)
|
||||
{
|
||||
throw new \Exception('Request body could not be parsed (probably invalid JSON format or missing/wrong Content-Type header)');
|
||||
}
|
||||
|
||||
if (!array_key_exists('amount', $requestBody))
|
||||
{
|
||||
throw new \Exception('An amount is required');
|
||||
}
|
||||
|
||||
if (!array_key_exists('location_id_from', $requestBody))
|
||||
{
|
||||
throw new \Exception('A transfer from location is required');
|
||||
}
|
||||
|
||||
if (!array_key_exists('location_id_to', $requestBody))
|
||||
{
|
||||
throw new \Exception('A transfer to location is required');
|
||||
}
|
||||
|
||||
$specificStockEntryId = 'default';
|
||||
if (array_key_exists('stock_entry_id', $requestBody) && !empty($requestBody['stock_entry_id']))
|
||||
{
|
||||
$specificStockEntryId = $requestBody['stock_entry_id'];
|
||||
}
|
||||
|
||||
$bookingId = $this->StockService->TransferProduct($args['productId'], $requestBody['amount'], $requestBody['location_id_to'], $requestBody['location_id_from'], $specificStockEntryId);
|
||||
return $this->ApiResponse($this->Database->stock_log($bookingId));
|
||||
}
|
||||
catch (\Exception $ex)
|
||||
{
|
||||
return $this->GenericErrorResponse($response, $ex->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function TransferProductByBarcode(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
|
||||
{
|
||||
try
|
||||
{
|
||||
$args['productId'] = $this->StockService->GetProductIdFromBarcode($args['barcode']);
|
||||
return $this->TransferProduct($request, $response, $args);
|
||||
}
|
||||
catch (\Exception $ex)
|
||||
{
|
||||
return $this->GenericErrorResponse($response, $ex->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function ConsumeProduct(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
|
||||
{
|
||||
$requestBody = $request->getParsedBody();
|
||||
|
|
@ -454,6 +508,11 @@ class StockApiController extends BaseApiController
|
|||
return $this->ApiResponse($this->StockService->GetProductStockEntries($args['productId']));
|
||||
}
|
||||
|
||||
public function ProductStockLocations(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
|
||||
{
|
||||
return $this->ApiResponse($this->StockService->GetProductStockLocations($args['productId']));
|
||||
}
|
||||
|
||||
public function StockBooking(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -55,6 +55,15 @@ class StockController extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
public function Transfer(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
|
||||
{
|
||||
return $this->AppContainer->view->render($response, 'transfer', [
|
||||
'products' => $this->Database->products()->orderBy('name'),
|
||||
'recipes' => $this->Database->recipes()->orderBy('name'),
|
||||
'locations' => $this->Database->locations()->orderBy('name')
|
||||
]);
|
||||
}
|
||||
|
||||
public function Inventory(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
|
||||
{
|
||||
return $this->AppContainer->view->render($response, 'inventory', [
|
||||
|
|
@ -272,6 +281,7 @@ class StockController extends BaseController
|
|||
{
|
||||
return $this->AppContainer->view->render($response, 'stockjournal', [
|
||||
'stockLog' => $this->Database->stock_log()->orderBy('row_created_timestamp', 'DESC'),
|
||||
'locations' => $this->Database->locations()->orderBy('name'),
|
||||
'products' => $this->Database->products()->orderBy('name'),
|
||||
'quantityunits' => $this->Database->quantity_units()->orderBy('name')
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1128,6 +1128,50 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/stock/products/{productId}/locations": {
|
||||
"get": {
|
||||
"summary": "Returns all locations with current stock",
|
||||
"tags": [
|
||||
"Stock"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "productId",
|
||||
"required": true,
|
||||
"description": "A valid product id",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "An array of StockLocation objects",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StockLocation"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The operation was not successful (possible errors are: Not existing product)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GenericErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/stock/products/{productId}/entries": {
|
||||
"get": {
|
||||
"summary": "Returns all stock entries of the given product in order of next use (first expiring first, then first in first out)",
|
||||
|
|
@ -1376,6 +1420,86 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/stock/products/{productId}/transfer": {
|
||||
"post": {
|
||||
"summary": "Transfer the given amount of the given product from stock from one location to another",
|
||||
"tags": [
|
||||
"Stock"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "productId",
|
||||
"required": true,
|
||||
"description": "A valid product id",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"description": "The amount to remove - please note that when tare weight handling for the product is enabled, this needs to be the amount including the container weight (gross), the amount to be posted will be automatically calculated based on what is in stock and the defined tare weight"
|
||||
},
|
||||
"transaction_type": {
|
||||
"$ref": "#/components/internalSchemas/StockTransactionType"
|
||||
},
|
||||
"location_from_id": {
|
||||
"type": "number",
|
||||
"format": "integer",
|
||||
"description": "Location the stock is transfering from"
|
||||
},
|
||||
"location_to_id": {
|
||||
"type": "number",
|
||||
"format": "integer",
|
||||
"description": "Location the stock is transfering to"
|
||||
},
|
||||
"stock_entry_id": {
|
||||
"type": "string",
|
||||
"description": "A specific stock entry id to consume, if used, the amount has to be 1"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"amount": 1,
|
||||
"transaction_type": "transfer",
|
||||
"location_from_id": 1,
|
||||
"location_to_id": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The operation was successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StockLogEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The operation was not successful (possible errors are: Not existing product, invalid transaction type, given amount > current stock amount)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GenericErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/stock/products/{productId}/inventory": {
|
||||
"post": {
|
||||
"summary": "Inventories the given product (adds/removes based on the given new amount)",
|
||||
|
|
@ -3023,6 +3147,29 @@
|
|||
"row_created_timestamp": "2019-05-02 20:12:25"
|
||||
}
|
||||
},
|
||||
"StockLocation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"product_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"location_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"id": "1",
|
||||
"product_id": "3",
|
||||
"location_id": "1",
|
||||
"name": "Fridge"
|
||||
}
|
||||
},
|
||||
"StockEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ msgstr ""
|
|||
msgid "purchase"
|
||||
msgstr "Purchase"
|
||||
|
||||
msgid "transfer_to"
|
||||
msgstr "Transfer To"
|
||||
|
||||
msgid "transfer_from"
|
||||
msgstr "Transfer From"
|
||||
|
||||
msgid "consume"
|
||||
msgstr "Consume"
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ msgstr ""
|
|||
msgid "purchase"
|
||||
msgstr ""
|
||||
|
||||
msgid "transfer_from"
|
||||
msgstr ""
|
||||
|
||||
msgid "transfer_to"
|
||||
msgstr ""
|
||||
|
||||
msgid "consume"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1567,3 +1567,15 @@ msgstr ""
|
|||
|
||||
msgid "This means the next execution of this chore should only be scheduled every %s years"
|
||||
msgstr ""
|
||||
|
||||
msgid "Transfer"
|
||||
msgstr ""
|
||||
|
||||
msgid "From location"
|
||||
msgstr ""
|
||||
|
||||
msgid "To location"
|
||||
msgstr ""
|
||||
|
||||
msgid "There are no units available at this location"
|
||||
msgstr ""
|
||||
|
|
|
|||
11
migrations/0095.sql
Normal file
11
migrations/0095.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
DROP VIEW stock_current_locations;
|
||||
CREATE VIEW stock_current_locations AS
|
||||
SELECT
|
||||
s.id,
|
||||
s.product_id,
|
||||
IFNULL(s.location_id, p.location_id) AS location_id,
|
||||
l.name AS name
|
||||
FROM stock s
|
||||
JOIN products p ON s.product_id = p.id
|
||||
JOIN locations l on IFNULL(s.location_id, p.location_id) = l.id
|
||||
GROUP BY s.product_id, IFNULL(s.location_id, p.location_id)
|
||||
|
|
@ -219,6 +219,30 @@ $(document).on("click", ".product-purchase-button", function(e)
|
|||
});
|
||||
});
|
||||
|
||||
$(document).on("click", ".product-transfer-custom-amount-button", function(e)
|
||||
{
|
||||
e.preventDefault();
|
||||
|
||||
var productId = $(e.currentTarget).attr("data-product-id");
|
||||
|
||||
bootbox.dialog({
|
||||
message: '<iframe height="650px" class="embed-responsive" src="' + U("/transfer?embedded&product=") + productId.toString() + '"></iframe>',
|
||||
size: 'large',
|
||||
backdrop: true,
|
||||
closeButton: false,
|
||||
buttons: {
|
||||
cancel: {
|
||||
label: __t('Cancel'),
|
||||
className: 'btn-secondary responsive-button',
|
||||
callback: function()
|
||||
{
|
||||
bootbox.hideAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", ".product-consume-custom-amount-button", function(e)
|
||||
{
|
||||
e.preventDefault();
|
||||
|
|
|
|||
375
public/viewjs/transfer.js
Normal file
375
public/viewjs/transfer.js
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
$('#save-transfer-button').on('click', function(e)
|
||||
{
|
||||
e.preventDefault();
|
||||
|
||||
var jsonForm = $('#transfer-form').serializeJSON();
|
||||
Grocy.FrontendHelpers.BeginUiBusy("transfer-form");
|
||||
|
||||
var apiUrl = 'stock/products/' + jsonForm.product_id + '/transfer';
|
||||
|
||||
var jsonData = {};
|
||||
jsonData.amount = jsonForm.amount;
|
||||
jsonData.location_id_to = $("#location_id_to").val();
|
||||
jsonData.location_id_from = $("#location_id_from").val();
|
||||
|
||||
if ($("#use_specific_stock_entry").is(":checked"))
|
||||
{
|
||||
jsonData.stock_entry_id = jsonForm.specific_stock_entry;
|
||||
}
|
||||
|
||||
Grocy.Api.Get('stock/products/' + jsonForm.product_id,
|
||||
function(productDetails)
|
||||
{
|
||||
Grocy.Api.Post(apiUrl, jsonData,
|
||||
function(result)
|
||||
{
|
||||
var addBarcode = GetUriParam('addbarcodetoselection');
|
||||
if (addBarcode !== undefined)
|
||||
{
|
||||
var existingBarcodes = productDetails.product.barcode || '';
|
||||
if (existingBarcodes.length === 0)
|
||||
{
|
||||
productDetails.product.barcode = addBarcode;
|
||||
}
|
||||
else
|
||||
{
|
||||
productDetails.product.barcode += ',' + addBarcode;
|
||||
}
|
||||
|
||||
Grocy.Api.Put('objects/products/' + productDetails.product.id, productDetails.product,
|
||||
function(result)
|
||||
{
|
||||
$("#flow-info-addbarcodetoselection").addClass("d-none");
|
||||
$('#barcode-lookup-disabled-hint').addClass('d-none');
|
||||
window.history.replaceState({ }, document.title, U("/transfer"));
|
||||
},
|
||||
function(xhr)
|
||||
{
|
||||
console.error(xhr);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (productDetails.product.enable_tare_weight_handling == 1)
|
||||
{
|
||||
var successMessage = __t('Transfered %1$s of %2$s stock from %3$s to %4$s', Math.abs(jsonForm.amount - parseFloat(productDetails.product.tare_weight)) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name,$('option:selected', "#location_id_from").text(), $('option:selected', "#location_id_to").text());
|
||||
}
|
||||
else
|
||||
{
|
||||
var successMessage =__t('Transfered %1$s of %2$s stock from %3$s to %4$s', Math.abs(jsonForm.amount) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name, $('option:selected', "#location_id_from").text(), $('option:selected', "#location_id_to").text());
|
||||
}
|
||||
|
||||
if (GetUriParam("embedded") !== undefined)
|
||||
{
|
||||
window.parent.postMessage(WindowMessageBag("ProductChanged", jsonForm.product_id), Grocy.BaseUrl);
|
||||
window.parent.postMessage(WindowMessageBag("ShowSuccessMessage", successMessage), Grocy.BaseUrl);
|
||||
window.parent.postMessage(WindowMessageBag("CloseAllModals"), Grocy.BaseUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
Grocy.FrontendHelpers.EndUiBusy("transfer-form");
|
||||
toastr.success(successMessage);
|
||||
|
||||
$("#specific_stock_entry").find("option").remove().end().append("<option></option>");
|
||||
$("#specific_stock_entry").attr("disabled", "");
|
||||
$("#specific_stock_entry").removeAttr("required");
|
||||
if ($("#use_specific_stock_entry").is(":checked"))
|
||||
{
|
||||
$("#use_specific_stock_entry").click();
|
||||
}
|
||||
|
||||
$("#location_id_from").find("option").remove().end().append("<option></option>");
|
||||
$("#amount").attr("min", "1");
|
||||
$("#amount").attr("max", "999999");
|
||||
$("#amount").attr("step", "1");
|
||||
$("#amount").parent().find(".invalid-feedback").text(__t('The amount cannot be lower than %s', '1'));
|
||||
$('#amount').val(Grocy.UserSettings.stock_default_transfer_amount);
|
||||
$('#amount_qu_unit').text("");
|
||||
$("#tare-weight-handling-info").addClass("d-none");
|
||||
Grocy.Components.ProductPicker.Clear();
|
||||
$("#location_id_to").val("");
|
||||
$("#location_id_from").val("");
|
||||
Grocy.Components.ProductPicker.GetInputElement().focus();
|
||||
Grocy.FrontendHelpers.ValidateForm('transfer-form');
|
||||
}
|
||||
},
|
||||
function(xhr)
|
||||
{
|
||||
Grocy.FrontendHelpers.EndUiBusy("transfer-form");
|
||||
console.error(xhr);
|
||||
}
|
||||
);
|
||||
},
|
||||
function(xhr)
|
||||
{
|
||||
Grocy.FrontendHelpers.EndUiBusy("transfer-form");
|
||||
console.error(xhr);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
|
||||
{
|
||||
$("#specific_stock_entry").find("option").remove().end().append("<option></option>");
|
||||
if ($("#use_specific_stock_entry").is(":checked"))
|
||||
{
|
||||
$("#use_specific_stock_entry").click();
|
||||
}
|
||||
$("#location_id_to").val("");
|
||||
$("#location_id_from").val("");
|
||||
|
||||
var productId = $(e.target).val();
|
||||
|
||||
if (productId)
|
||||
{
|
||||
Grocy.Components.ProductCard.Refresh(productId);
|
||||
|
||||
Grocy.Api.Get('stock/products/' + productId,
|
||||
function(productDetails)
|
||||
{
|
||||
if (productDetails.product.enable_tare_weight_handling == 1) {
|
||||
Grocy.Components.ProductPicker.GetPicker().parent().find(".invalid-feedback").text(__t('Products with Tare weight enabled are currently not supported for Transfer. Please select another product.'));
|
||||
Grocy.Components.ProductPicker.Clear();
|
||||
return;
|
||||
}
|
||||
$('#amount_qu_unit').text(productDetails.quantity_unit_stock.name);
|
||||
|
||||
$("#location_id_from").find("option").remove().end().append("<option></option>");
|
||||
Grocy.Api.Get("stock/products/" + productId + '/locations',
|
||||
function(stockLocations)
|
||||
{
|
||||
var setDefault = 0;
|
||||
stockLocations.forEach(stockLocation =>
|
||||
{
|
||||
if (productDetails.location.id == stockLocation.location_id) {
|
||||
$("#location_id_from").append($("<option>", {
|
||||
value: stockLocation.location_id,
|
||||
text: __t("%1$s (default location)", stockLocation.name)
|
||||
}));
|
||||
$("#location_id_from").val(productDetails.location.id);
|
||||
$("#location_id_from").trigger('change');
|
||||
setDefault = 1;
|
||||
} else {
|
||||
$("#location_id_from").append($("<option>", {
|
||||
value: stockLocation.location_id,
|
||||
text: __t("%1$s", stockLocation.name)
|
||||
}));
|
||||
}
|
||||
if (setDefault == 0) {
|
||||
$("#location_id_from").val(stockLocation.location_id);
|
||||
$("#location_id_from").trigger('change');
|
||||
}
|
||||
});
|
||||
},
|
||||
function(xhr)
|
||||
{
|
||||
console.error(xhr);
|
||||
}
|
||||
);
|
||||
|
||||
if (productDetails.product.allow_partial_units_in_stock == 1)
|
||||
{
|
||||
$("#amount").attr("min", "0.01");
|
||||
$("#amount").attr("step", "0.01");
|
||||
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', 0.01.toLocaleString(), parseFloat(productDetails.stock_amount).toLocaleString()));
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#amount").attr("min", "1");
|
||||
$("#amount").attr("step", "1");
|
||||
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", parseFloat(productDetails.stock_amount).toLocaleString()));
|
||||
}
|
||||
|
||||
if (productDetails.product.enable_tare_weight_handling == 1)
|
||||
{
|
||||
$("#amount").attr("min", productDetails.product.tare_weight);
|
||||
$("#tare-weight-handling-info").removeClass("d-none");
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#tare-weight-handling-info").addClass("d-none");
|
||||
}
|
||||
|
||||
if ((parseFloat(productDetails.stock_amount) || 0) === 0)
|
||||
{
|
||||
Grocy.Components.ProductPicker.Clear();
|
||||
Grocy.FrontendHelpers.ValidateForm('transfer-form');
|
||||
Grocy.Components.ProductPicker.ShowCustomError(__t('This product is not in stock'));
|
||||
Grocy.Components.ProductPicker.GetInputElement().focus();
|
||||
}
|
||||
else
|
||||
{
|
||||
Grocy.Components.ProductPicker.HideCustomError();
|
||||
Grocy.FrontendHelpers.ValidateForm('transfer-form');
|
||||
$('#amount').focus();
|
||||
}
|
||||
},
|
||||
function(xhr)
|
||||
{
|
||||
console.error(xhr);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$('#amount').val(Grocy.UserSettings.stock_default_transfer_amount);
|
||||
Grocy.Components.ProductPicker.GetPicker().trigger('change');
|
||||
Grocy.Components.ProductPicker.GetInputElement().focus();
|
||||
Grocy.FrontendHelpers.ValidateForm('transfer-form');
|
||||
|
||||
$("#location_id_from").on('change', function(e)
|
||||
{
|
||||
var locationId = $(e.target).val();
|
||||
var sumValue = 0;
|
||||
|
||||
if (locationId == $("#location_id_to").val())
|
||||
{
|
||||
$("#location_id_to").val("");
|
||||
}
|
||||
|
||||
$("#specific_stock_entry").find("option").remove().end().append("<option></option>");
|
||||
if ($("#use_specific_stock_entry").is(":checked"))
|
||||
{
|
||||
$("#use_specific_stock_entry").click();
|
||||
}
|
||||
|
||||
if (locationId)
|
||||
{
|
||||
Grocy.Api.Get("stock/products/" + Grocy.Components.ProductPicker.GetValue() + '/entries',
|
||||
function(stockEntries)
|
||||
{
|
||||
stockEntries.forEach(stockEntry =>
|
||||
{
|
||||
if (stockEntry.location_id == locationId)
|
||||
{
|
||||
$("#specific_stock_entry").append($("<option>", {
|
||||
value: stockEntry.stock_id,
|
||||
amount: stockEntry.amount,
|
||||
text: __t("Amount remaining: %1$s, Best Before: %2$s", stockEntry.amount, stockEntry.best_before_date)
|
||||
}));
|
||||
sumValue = sumValue + parseFloat(stockEntry.amount);
|
||||
}
|
||||
|
||||
if (stockEntry.location_id === null)
|
||||
{
|
||||
$("#specific_stock_entry").append($("<option>", {
|
||||
value: stockEntry.stock_id,
|
||||
amount: stockEntry.amount,
|
||||
text: __t("Amount remaining: %1$s, Best Before: %2$s", stockEntry.amount, stockEntry.best_before_date)
|
||||
}));
|
||||
sumValue = sumValue + parseFloat(stockEntry.amount);
|
||||
}
|
||||
});
|
||||
$("#amount").attr("max", sumValue);
|
||||
if (sumValue == 0)
|
||||
{
|
||||
$("#amount").parent().find(".invalid-feedback").text(__t('There are no units available at this location'));
|
||||
} else {
|
||||
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", sumValue));
|
||||
}
|
||||
},
|
||||
function(xhr)
|
||||
{
|
||||
console.error(xhr);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$("#location_id_to").on('change', function(e)
|
||||
{
|
||||
var locationId = $(e.target).val();
|
||||
|
||||
if (locationId == $("#location_id_from").val())
|
||||
{
|
||||
$("#location_id_to").parent().find(".invalid-feedback").text(__t('This cannot be the same as the "From" location'));
|
||||
$("#location_id_to").val("");
|
||||
}
|
||||
});
|
||||
|
||||
$('#amount').on('focus', function(e)
|
||||
{
|
||||
$(this).select();
|
||||
});
|
||||
|
||||
$('#transfer-form input').keyup(function(event)
|
||||
{
|
||||
Grocy.FrontendHelpers.ValidateForm('transfer-form');
|
||||
});
|
||||
|
||||
$('#transfer-form select').change(function(event)
|
||||
{
|
||||
Grocy.FrontendHelpers.ValidateForm('transfer-form');
|
||||
});
|
||||
|
||||
$('#transfer-form input').keydown(function(event)
|
||||
{
|
||||
if (event.keyCode === 13) //Enter
|
||||
{
|
||||
event.preventDefault();
|
||||
|
||||
if (document.getElementById('transfer-form').checkValidity() === false) //There is at least one validation error
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
$('#save-transfer-button').click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$("#specific_stock_entry").on("change", function(e)
|
||||
{
|
||||
if ($(e.target).val() == "")
|
||||
{
|
||||
var sumValue = 0;
|
||||
Grocy.Api.Get("stock/products/" + Grocy.Components.ProductPicker.GetValue() + '/entries',
|
||||
function(stockEntries)
|
||||
{
|
||||
stockEntries.forEach(stockEntry =>
|
||||
{
|
||||
if (stockEntry.location_id == $("#location_id_from").val() || stockEntry.location_id == "")
|
||||
{
|
||||
sumValue = sumValue + parseFloat(stockEntry.amount);
|
||||
}
|
||||
});
|
||||
$("#amount").attr("max", sumValue);
|
||||
if (sumValue == 0)
|
||||
{
|
||||
$("#amount").parent().find(".invalid-feedback").text(__t('There are no units available at this location'));
|
||||
} else {
|
||||
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", sumValue));
|
||||
}
|
||||
},
|
||||
function(xhr)
|
||||
{
|
||||
console.error(xhr);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", $('option:selected', this).attr('amount')));
|
||||
$("#amount").attr("max", $('option:selected', this).attr('amount'));
|
||||
}
|
||||
});
|
||||
|
||||
$("#use_specific_stock_entry").on("change", function()
|
||||
{
|
||||
var value = $(this).is(":checked");
|
||||
|
||||
if (value)
|
||||
{
|
||||
$("#specific_stock_entry").removeAttr("disabled");
|
||||
$("#specific_stock_entry").attr("required", "");
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#specific_stock_entry").attr("disabled", "");
|
||||
$("#specific_stock_entry").removeAttr("required");
|
||||
$("#specific_stock_entry").val("");
|
||||
$("#location_id_from").trigger('change');
|
||||
}
|
||||
|
||||
Grocy.FrontendHelpers.ValidateForm("transfer-form");
|
||||
});
|
||||
|
|
@ -35,6 +35,7 @@ $app->group('', function()
|
|||
$this->get('/stockoverview', '\Grocy\Controllers\StockController:Overview');
|
||||
$this->get('/purchase', '\Grocy\Controllers\StockController:Purchase');
|
||||
$this->get('/consume', '\Grocy\Controllers\StockController:Consume');
|
||||
$this->get('/transfer', '\Grocy\Controllers\StockController:Transfer');
|
||||
$this->get('/inventory', '\Grocy\Controllers\StockController:Inventory');
|
||||
$this->get('/products', '\Grocy\Controllers\StockController:ProductsList');
|
||||
$this->get('/product/{productId}', '\Grocy\Controllers\StockController:ProductEditForm');
|
||||
|
|
@ -161,14 +162,17 @@ $app->group('/api', function()
|
|||
$this->get('/stock/volatile', '\Grocy\Controllers\StockApiController:CurrentVolatilStock');
|
||||
$this->get('/stock/products/{productId}', '\Grocy\Controllers\StockApiController:ProductDetails');
|
||||
$this->get('/stock/products/{productId}/entries', '\Grocy\Controllers\StockApiController:ProductStockEntries');
|
||||
$this->get('/stock/products/{productId}/locations', '\Grocy\Controllers\StockApiController:ProductStockLocations');
|
||||
$this->get('/stock/products/{productId}/price-history', '\Grocy\Controllers\StockApiController:ProductPriceHistory');
|
||||
$this->post('/stock/products/{productId}/add', '\Grocy\Controllers\StockApiController:AddProduct');
|
||||
$this->post('/stock/products/{productId}/consume', '\Grocy\Controllers\StockApiController:ConsumeProduct');
|
||||
$this->post('/stock/products/{productId}/transfer', '\Grocy\Controllers\StockApiController:TransferProduct');
|
||||
$this->post('/stock/products/{productId}/inventory', '\Grocy\Controllers\StockApiController:InventoryProduct');
|
||||
$this->post('/stock/products/{productId}/open', '\Grocy\Controllers\StockApiController:OpenProduct');
|
||||
$this->get('/stock/products/by-barcode/{barcode}', '\Grocy\Controllers\StockApiController:ProductDetailsByBarcode');
|
||||
$this->post('/stock/products/by-barcode/{barcode}/add', '\Grocy\Controllers\StockApiController:AddProductByBarcode');
|
||||
$this->post('/stock/products/by-barcode/{barcode}/consume', '\Grocy\Controllers\StockApiController:ConsumeProductByBarcode');
|
||||
$this->post('/stock/products/by-barcode/{barcode}/transfer', '\Grocy\Controllers\StockApiController:TransferProductByBarcode');
|
||||
$this->post('/stock/products/by-barcode/{barcode}/inventory', '\Grocy\Controllers\StockApiController:InventoryProductByBarcode');
|
||||
$this->post('/stock/products/by-barcode/{barcode}/open', '\Grocy\Controllers\StockApiController:OpenProductByBarcode');
|
||||
$this->get('/stock/bookings/{bookingId}', '\Grocy\Controllers\StockApiController:StockBooking');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ class StockService extends BaseService
|
|||
{
|
||||
const TRANSACTION_TYPE_PURCHASE = 'purchase';
|
||||
const TRANSACTION_TYPE_CONSUME = 'consume';
|
||||
const TRANSACTION_TYPE_TRANSFER_FROM = 'transfer_from';
|
||||
const TRANSACTION_TYPE_TRANSFER_TO = 'transfer_to';
|
||||
const TRANSACTION_TYPE_INVENTORY_CORRECTION = 'inventory-correction';
|
||||
const TRANSACTION_TYPE_PRODUCT_OPENED = 'product-opened';
|
||||
|
||||
|
|
@ -176,7 +178,12 @@ class StockService extends BaseService
|
|||
}
|
||||
}
|
||||
|
||||
public function GetProductStockEntriesByLocation($productId, $locationId, $excludeOpened = false)
|
||||
public function GetProductStockLocations($productId)
|
||||
{
|
||||
return $this->Database->stock_current_locations()->where('product_id', $productId)->fetchAll();
|
||||
}
|
||||
|
||||
public function GetProductStockEntriesByLocation($productId, $locationId, $checkNullLocation = false, $excludeOpened = false)
|
||||
{
|
||||
// In order of next use:
|
||||
// First expiring first, then first in first out
|
||||
|
|
@ -185,9 +192,11 @@ class StockService extends BaseService
|
|||
{
|
||||
return $this->Database->stock()->where('product_id = :1 AND location_id = :2 AND open = 0', $productId, $locationId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll();
|
||||
}
|
||||
else
|
||||
else if ($checkNullLocation)
|
||||
{
|
||||
return $this->Database->stock()->where('product_id = :1 AND location_id = :2', $productId, $locationId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll();
|
||||
return $this->Database->stock()->where('product_id = :1 AND (location_id = :2 OR location_id is null)', $productId, $locationId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll();
|
||||
} else {
|
||||
return $this->Database->stock()->where('product_id = :1 AND location_id = :2', $productId, $locationId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +238,7 @@ class StockService extends BaseService
|
|||
}
|
||||
}
|
||||
|
||||
if ($transactionType === self::TRANSACTION_TYPE_PURCHASE || $transactionType === self::TRANSACTION_TYPE_INVENTORY_CORRECTION)
|
||||
if ($transactionType === self::TRANSACTION_TYPE_PURCHASE || $transactionType === self::TRANSACTION_TYPE_INVENTORY_CORRECTION || $transactionType === self::TRANSACTION_TYPE_TRANSFER_TO)
|
||||
{
|
||||
//Check to see if this is already in stock at this location
|
||||
$stockRows = $this->Database->stock()->where('product_id = :1 AND best_before_date = :2 AND purchased_date = :3 AND price = :4', $productId, $bestBeforeDate, $purchasedDate, $price)->fetchAll();
|
||||
|
|
@ -327,6 +336,98 @@ class StockService extends BaseService
|
|||
}
|
||||
}
|
||||
|
||||
public function TransferProduct(int $productId, float $amount, int $locationId, int $locationFromId, $specificStockEntryId = 'default')
|
||||
{
|
||||
if (!$this->ProductExists($productId))
|
||||
{
|
||||
throw new \Exception('Product does not exist');
|
||||
}
|
||||
if (!$this->LocationExists($locationId))
|
||||
{
|
||||
throw new \Exception('Transfer location does not exist');
|
||||
}
|
||||
|
||||
// Tare weight handling
|
||||
// The given amount is the new total amount including the container weight (gross)
|
||||
// The amount to be posted needs to be the absolute value of the given amount - stock amount - tare weight
|
||||
$productDetails = (object)$this->GetProductDetails($productId);
|
||||
if ($productDetails->product->enable_tare_weight_handling == 1)
|
||||
{
|
||||
if ($amount < floatval($productDetails->product->tare_weight))
|
||||
{
|
||||
throw new \Exception('The amount cannot be lower than the defined tare weight');
|
||||
}
|
||||
|
||||
$amount = abs($amount - floatval($productDetails->stock_amount) - floatval($productDetails->product->tare_weight));
|
||||
}
|
||||
|
||||
$checkNullLocation = $productDetails->product->location_id == $locationFromId;
|
||||
$potentialStockEntries = $this->GetProductStockEntriesByLocation($productId, $locationFromId, $checkNullLocation);
|
||||
|
||||
if ($specificStockEntryId !== 'default')
|
||||
{
|
||||
$potentialStockEntries = FindAllObjectsInArrayByPropertyValue($potentialStockEntries, 'stock_id', $specificStockEntryId);
|
||||
}
|
||||
|
||||
if ($checkNullLocation)
|
||||
{
|
||||
$productStockAmountByLocation = $this->Database->stock()->where('product_id = :1 AND (location_id = :2 OR location_id is null)', $productId, $locationFromId)->sum('amount');
|
||||
} else {
|
||||
$productStockAmountByLocation = $this->Database->stock()->where('product_id = :1 AND location_id = :2', $productId, $locationFromId)->sum('amount');
|
||||
}
|
||||
if ($amount > $productStockAmountByLocation)
|
||||
{
|
||||
throw new \Exception('Amount to be transfered cannot be > current stock amount');
|
||||
}
|
||||
|
||||
foreach ($potentialStockEntries as $stockEntry)
|
||||
{
|
||||
if ($amount == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if ($amount >= $stockEntry->amount)
|
||||
{
|
||||
$logRow = $this->Database->stock_log()->createRow(array(
|
||||
'product_id' => $productId,
|
||||
'amount' => $stockEntry->amount * -1,
|
||||
'best_before_date' => $stockEntry->best_before_date,
|
||||
'purchased_date' => $stockEntry->purchased_date,
|
||||
'stock_id' => $stockEntry->stock_id,
|
||||
'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_FROM,
|
||||
'price' => $stockEntry->price,
|
||||
'location_id' => $stockEntry->location_id
|
||||
));
|
||||
$logRow->save();
|
||||
//Add the amount into the new location
|
||||
$this->AddProduct($productId, $stockEntry->amount, $stockEntry->best_before_date, self::TRANSACTION_TYPE_TRANSFER_TO, $stockEntry->purchased_date, $stockEntry->price, $locationId);
|
||||
|
||||
$amount -= $stockEntry->amount;
|
||||
$stockEntry->delete();
|
||||
} else {
|
||||
$logRow = $this->Database->stock_log()->createRow(array(
|
||||
'product_id' => $productId,
|
||||
'amount' => $amount * -1,
|
||||
'best_before_date' => $stockEntry->best_before_date,
|
||||
'purchased_date' => $stockEntry->purchased_date,
|
||||
'stock_id' => $stockEntry->stock_id,
|
||||
'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_FROM,
|
||||
'price' => $stockEntry->price,
|
||||
'location_id' => $stockEntry->location_id
|
||||
));
|
||||
$logRow->save();
|
||||
|
||||
//Add the amount into the new location
|
||||
$this->AddProduct($productId, $amount, $stockEntry->best_before_date, self::TRANSACTION_TYPE_TRANSFER_TO, $stockEntry->purchased_date, $stockEntry->price, $locationId);
|
||||
|
||||
$stockEntry->update(array(
|
||||
'amount' => $stockEntry->amount - $amount
|
||||
));
|
||||
$amount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function ConsumeProduct(int $productId, float $amount, bool $spoiled, $transactionType, $specificStockEntryId = 'default', $recipeId = null)
|
||||
{
|
||||
if (!$this->ProductExists($productId))
|
||||
|
|
@ -772,7 +873,7 @@ class StockService extends BaseService
|
|||
'undone_timestamp' => date('Y-m-d H:i:s')
|
||||
));
|
||||
}
|
||||
elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_CONSUME || ($logRow->transaction_type === self::TRANSACTION_TYPE_INVENTORY_CORRECTION && $logRow->amount < 0))
|
||||
else if ($logRow->transaction_type === self::TRANSACTION_TYPE_CONSUME || ($logRow->transaction_type === self::TRANSACTION_TYPE_INVENTORY_CORRECTION && $logRow->amount < 0))
|
||||
{
|
||||
// Add corresponding amount back to stock
|
||||
if ($stockRow == null) {
|
||||
|
|
@ -799,7 +900,7 @@ class StockService extends BaseService
|
|||
'undone_timestamp' => date('Y-m-d H:i:s')
|
||||
));
|
||||
}
|
||||
elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_PRODUCT_OPENED)
|
||||
else if ($logRow->transaction_type === self::TRANSACTION_TYPE_PRODUCT_OPENED)
|
||||
{
|
||||
// Remove opened flag from corresponding log entry
|
||||
$stockRows = $this->Database->stock()->where('stock_id = :1 AND amount = :2 AND purchased_date = :3', $logRow->stock_id, $logRow->amount, $logRow->purchased_date)->limit(1);
|
||||
|
|
|
|||
|
|
@ -157,6 +157,14 @@
|
|||
<span class="nav-link-text">{{ $__t('Consume') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
|
||||
<li class="nav-item" data-toggle="tooltip" data-placement="right" title="{{ $__t('Transfer') }}" data-nav-for-page="transfer">
|
||||
<a class="nav-link discrete-link" href="{{ $U('/transfer') }}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
<span class="nav-link-text">{{ $__t('Transfer') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
<li class="nav-item" data-toggle="tooltip" data-placement="right" title="{{ $__t('Inventory') }}" data-nav-for-page="inventory">
|
||||
<a class="nav-link discrete-link" href="{{ $U('/inventory') }}">
|
||||
<i class="fas fa-list"></i>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
<th>{{ $__t('Amount') }}</th>
|
||||
<th>{{ $__t('Booking time') }}</th>
|
||||
<th>{{ $__t('Booking type') }}</th>
|
||||
<th class="@if(!GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) d-none @endif">{{ $__t('Location') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="d-none">
|
||||
|
|
@ -65,6 +66,9 @@
|
|||
<td>
|
||||
{{ $__t($stockLogEntry->transaction_type) }}
|
||||
</td>
|
||||
<td class="@if(!GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) d-none @endif">
|
||||
{{ FindObjectInArrayByPropertyValue($locations, 'id', $stockLogEntry->location_id)->name }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@
|
|||
data-product-id="{{ $currentStockEntry->product_id }}">
|
||||
<i class="fas fa-utensils"></i> {{ $__t('Consume') }}
|
||||
</a>
|
||||
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
|
||||
<a class="dropdown-item product-transfer-custom-amount-button @if($currentStockEntry->amount < 1) disabled @endif" type="button" href="#"
|
||||
data-product-id="{{ $currentStockEntry->product_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') }}
|
||||
|
|
|
|||
84
views/transfer.blade.php
Normal file
84
views/transfer.blade.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
@extends('layout.default')
|
||||
|
||||
@section('title', $__t('Transfer'))
|
||||
@section('activeNav', 'transfer')
|
||||
@section('viewJsName', 'transfer')
|
||||
|
||||
@section('content')
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-6 col-xl-4 pb-3">
|
||||
<h1>@yield('title')</h1>
|
||||
|
||||
<form id="transfer-form" novalidate>
|
||||
|
||||
@include('components.productpicker', array(
|
||||
'products' => $products,
|
||||
'nextInputSelector' => '#location_id_from',
|
||||
'disallowAddProductWorkflows' => true
|
||||
))
|
||||
|
||||
@php /*@include('components.locationpicker', array(
|
||||
'id' => 'location_from',
|
||||
'locations' => $locations,
|
||||
'isRequired' => true,
|
||||
'label' => 'Transfer From Location'
|
||||
))*/ @endphp
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location_id_from">{{ $__t('From location') }}</label>
|
||||
<select required class="form-control location-combobox" id="location_id_from" name="location_id_from">
|
||||
<option></option>
|
||||
@foreach($locations as $location)
|
||||
<option value="{{ $location->id }}">{{ $location->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<div class="invalid-feedback">{{ $__t('A location is required') }}</div>
|
||||
</div>
|
||||
|
||||
@include('components.numberpicker', array(
|
||||
'id' => 'amount',
|
||||
'label' => 'Amount',
|
||||
'hintId' => 'amount_qu_unit',
|
||||
'min' => 1,
|
||||
'value' => 1,
|
||||
'invalidFeedback' => $__t('The amount cannot be lower than %s', '1'),
|
||||
'additionalHtmlContextHelp' => '<div id="tare-weight-handling-info" class="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>'
|
||||
))
|
||||
|
||||
<div class="form-group">
|
||||
<label for="use_specific_stock_entry">
|
||||
<input type="checkbox" id="use_specific_stock_entry" name="use_specific_stock_entry"> {{ $__t('Use a specific stock item') }}
|
||||
<span class="small text-muted">{{ $__t('The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"') }}</span>
|
||||
</label>
|
||||
<select disabled class="form-control" id="specific_stock_entry" name="specific_stock_entry">
|
||||
<option></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@php /*@include('components.locationpicker', array(
|
||||
'locations' => $locations,
|
||||
'isRequired' => true,
|
||||
'label' => 'Transfer to Location'
|
||||
))*/ @endphp
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location_id_to">{{ $__t('To location') }}</label>
|
||||
<select required class="form-control location-combobox" id="location_id_to" name="location_id_to">
|
||||
<option></option>
|
||||
@foreach($locations as $location)
|
||||
<option value="{{ $location->id }}">{{ $location->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<div class="invalid-feedback">{{ $__t('A location is required') }}</div>
|
||||
</div>
|
||||
|
||||
<button id="save-transfer-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
|
||||
Loading…
Reference in New Issue
Block a user