Transfer Products

This commit is contained in:
Kurt Riddlesperger 2019-10-13 20:12:48 -05:00
parent ec194a9dac
commit 18c607fe1d
15 changed files with 863 additions and 6 deletions

View File

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

View File

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

View File

@ -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": {

View File

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

View File

@ -15,6 +15,12 @@ msgstr ""
msgid "purchase"
msgstr ""
msgid "transfer_from"
msgstr ""
msgid "transfer_to"
msgstr ""
msgid "consume"
msgstr ""

View File

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

View File

@ -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
View 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");
});

View File

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

View File

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

View File

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

View File

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

View File

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