Implemented "default consume location" handling (closes #1365)

This commit is contained in:
Bernd Bestel 2022-04-03 21:15:05 +02:00
parent e69e7a9a9a
commit 61ed756dd0
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
6 changed files with 111 additions and 11 deletions

View File

@ -31,6 +31,13 @@
- Product card, stock overiew and stock entries page optimizations regarding displaying prices:
- Prices are now shown per default purchase quantity unit, instead of per stock QU and when clicking/hovering, a tooltip shows the price per stock QU
- The price history chart is now based on the value per purchase QU, instead of per stock QU
- New product option "Default consume location" (not mandatory, defaults to not set / empty)
- When set, stock entries at that location will be consumed first
- => This will be automatically taken into account when consuming from the stock overview page and all other places where no specific location can be selected
- => On the consume page the location is preselected in the following order:
- 1. The new default consume location, if the product currently has any stock there, otherwise
- 2. The products default location, if the product currently has any stock there, otherwise
- 3. The first location where the product currently has any stock
- New product option "Disable own stock" (defaults to disabled)
- When enabled, the corresponding product can't have own stock, means it will not be selectable on purchase (useful for parent products which are just used as a summary/total view of the child products)
- The location content sheet can now optionally list also out of stock products (at the products default location, new checkbox "Show only in-stock products " at the top of the page, defaults to enabled)

View File

@ -2350,3 +2350,9 @@ msgstr ""
msgid "Edit meal plan entry"
msgstr ""
msgid "Default consume location"
msgstr ""
msgid "Stock entries at this location will be consumed first"
msgstr ""

55
migrations/0187.sql Normal file
View File

@ -0,0 +1,55 @@
ALTER TABLE products
ADD default_consume_location_id INTEGER;
DROP VIEW stock_next_use;
CREATE VIEW stock_next_use
AS
/*
The default consume rule is:
Opened first, then first due first, then first in first out
Apart from that products at their default consume location should be consumed first
This orders the stock entries by that
=> Highest "priority" per product = the stock entry to use next
*/
SELECT
(ROW_NUMBER() OVER(PARTITION BY s.product_id ORDER BY CASE WHEN IFNULL(p.default_consume_location_id, -1) = s.location_id THEN 0 ELSE 1 END ASC, s.open DESC, s.best_before_date ASC, s.purchased_date ASC)) * -1 AS priority,
s.*
FROM stock s
JOIN products p
ON p.id = s.product_id;
CREATE TRIGGER stock_next_use_INS INSTEAD OF INSERT ON stock_next_use
BEGIN
INSERT INTO stock
(product_id, amount, best_before_date, purchased_date, stock_id,
price, open, opened_date, location_id, shopping_location_id, note)
VALUES
(NEW.product_id, NEW.amount, NEW.best_before_date, NEW.purchased_date, NEW.stock_id,
NEW.price, NEW.open, NEW.opened_date, NEW.location_id, NEW.shopping_location_id, NEW.note);
END;
CREATE TRIGGER stock_next_use_UPD INSTEAD OF UPDATE ON stock_next_use
BEGIN
UPDATE stock
SET product_id = NEW.product_id,
amount = NEW.amount,
best_before_date = NEW.best_before_date,
purchased_date = NEW.purchased_date,
stock_id = NEW.stock_id,
price = NEW.price,
open = NEW.open,
opened_date = NEW.opened_date,
location_id = NEW.location_id,
shopping_location_id = NEW.shopping_location_id,
note = NEW.note
WHERE id = NEW.id;
END;
CREATE TRIGGER stock_next_use_DEL INSTEAD OF DELETE ON stock_next_use
BEGIN
DELETE FROM stock
WHERE id = OLD.id;
END;

View File

@ -372,22 +372,30 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
RefreshLocaleNumberInput();
$(".input-group-productamountpicker").trigger("change");
var defaultLocationId = productDetails.location.id;
if (productDetails.product.default_consume_location_id != null && !productDetails.product.default_consume_location_id.isEmpty())
{
defaultLocationId = productDetails.product.default_consume_location_id;
}
$("#location_id").find("option").remove().end().append("<option></option>");
Grocy.Api.Get("stock/products/" + productId + '/locations?include_sub_products=true',
function(stockLocations)
{
var setDefault = 0;
var stockAmountAtDefaultLocation = 0;
stockLocations.forEach(stockLocation =>
{
if (productDetails.location.id == stockLocation.location_id)
if (stockLocation.location_id == defaultLocationId)
{
$("#location_id").append($("<option>", {
value: stockLocation.location_id,
text: stockLocation.location_name + " (" + __t("Default location") + ")"
}));
$("#location_id").val(productDetails.location.id);
$("#location_id").val(defaultLocationId);
$("#location_id").trigger('change');
setDefault = 1;
stockAmountAtDefaultLocation += Number.parseFloat(stockLocation.amount);
}
else
{
@ -399,11 +407,17 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
if (setDefault == 0)
{
$("#location_id").val(stockLocation.location_id);
$("#location_id").val(defaultLocationId);
$("#location_id").trigger('change');
}
});
if (stockAmountAtDefaultLocation == 0)
{
$("#location_id option")[1].selected = true;
$("#location_id").trigger('change');
}
if (document.getElementById("product_id").getAttribute("barcode") != "null")
{
Grocy.Api.Get('objects/product_barcodes?query[]=barcode=' + document.getElementById("product_id").getAttribute("barcode"),

View File

@ -406,10 +406,7 @@ class StockService extends BaseService
$potentialStockEntries = FindAllObjectsInArrayByPropertyValue($potentialStockEntries, 'stock_id', $specificStockEntryId);
}
// TODO: This check doesn't really check against products only at the given location
// (as GetProductDetails returns the stock_amount_aggregated of all locations)
// However, $potentialStockEntries are filtered accordingly, so this currently isn't really a problem at the end
$productStockAmount = ((object) $this->GetProductDetails($productId))->stock_amount_aggregated;
$productStockAmount = SumArrayValue($potentialStockEntries, 'amount');
if ($amount > $productStockAmount)
{
throw new \Exception('Amount to be consumed cannot be > current stock amount (if supplied, at the desired location)');
@ -823,13 +820,11 @@ class StockService extends BaseService
$sqlWhereAndOpen = 'AND open = 0';
}
$result = $this->getDatabase()->stock()->where($sqlWhereProductId . ' ' . $sqlWhereAndOpen);
$result = $this->getDatabase()->stock_next_use()->where($sqlWhereProductId . ' ' . $sqlWhereAndOpen);
// In order of next use:
// Opened first, then first due first, then first in first out
if ($ordered)
{
return $result->orderBy('open', 'DESC')->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC');
return $result->orderBy('product_id', 'ASC')->orderBy('priority', 'DESC');
}
return $result;

View File

@ -128,11 +128,34 @@
</select>
<div class="invalid-feedback">{{ $__t('A location is required') }}</div>
</div>
<div class="form-group">
<label for="default_consume_location_id">
{{ $__t('Default consume location') }}
<i class="fas fa-question-circle text-muted"
data-toggle="tooltip"
data-trigger="hover click"
title="{{ $__t('Stock entries at this location will be consumed first') }}"></i>
</label>
<select class="custom-control custom-select"
id="default_consume_location_id"
name="default_consume_location_id">
<option></option>
@foreach($locations as $location)
<option @if($mode=='edit'
&&
$location->id == $product->default_consume_location_id) selected="selected" @endif value="{{ $location->id }}">{{ $location->name }}</option>
@endforeach
</select>
</div>
@else
<input type="hidden"
name="location_id"
id="location_id"
value="1">
<input type="hidden"
name="default_consume_location_id"
id="default_consume_location_id"
value="1">
@endif
@php $prefillById = ''; if($mode=='edit') { $prefillById = $product->shopping_location_id; } @endphp