Implemented "Default quantity unit consume" (closes #1845)

This commit is contained in:
Bernd Bestel 2022-12-26 11:11:55 +01:00
parent 39d1f49431
commit 0585e80c70
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
10 changed files with 204 additions and 25 deletions

View File

@ -14,6 +14,10 @@
- The product option "Factor purchase to stock quantity unit" was removed
- => Use normal product specific QU conversions instead, if needed
- An existing "Factor purchase to stock quantity unit" was automatically migrated to a product specific QU conversion
- New product option "Default quantity unit consume"
- Will be used/selected as the default quantity unit on the consume page
- The product's "Quick consume amount" is now displayed related to this quantity unit ("quick consume/open buttons" on the stock overview page)
- Defaults to the product's "Quantity unit stock" (so no changed behavior when not configured)
- Fixed that hiding the "Purchased date" column (table options) on the stock entries page didn't work
- Fixed that the consumed amount was wrong, when consuming multiple substituted subproducts at once and when multiple/different conversion factors were involved
@ -63,8 +67,10 @@
- ⚠️ **Breaking changes**:
- The product property `qu_factor_purchase_to_stock` was removed (existing factors were migrated to normal product specific QU conversions, see above)
- The endpoint `/stock/products/{productId}` returns a new field/property `qu_conversion_factor_purchase_to_stock` for convenience (contains the conversion factor of the corresponding QU conversion from the product's qu_id_purchase to qu_id_stock)
- Numbers are now returned as numbers (so technically without quotes around them, were strings for nearly all endpoints before)
- Endpoint `/stock/products/{productId}`:
- Added a new field/property `qu_conversion_factor_purchase_to_stock` for convenience (contains the conversion factor of the corresponding QU conversion from the product's qu_id_purchase to qu_id_stock)
- Added a new field/property `default_quantity_unit_consume` (contains the quantity unit object of the product's "Default quantity unit consume")
- The following entities are now also available via the endpoint `/objects/{entity}` (only listing, no edit)
- `quantity_unit_conversions_resolved` (returns all final/resolved conversion factors per product and any directly or indirectly related quantity units)
- The endpoint `/batteries` now also returns the corresponding battery object (as field/property `battery`)

View File

@ -163,7 +163,7 @@ class StockController extends BaseController
'barcodes' => $this->getDatabase()->product_barcodes()->orderBy('barcode'),
'quantityunits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'),
'quantityunitsStock' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'),
'quantityunitsPurchase' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'),
'referencedQuantityunits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'),
'shoppinglocations' => $this->getDatabase()->shopping_locations()->orderBy('name', 'COLLATE NOCASE'),
'productgroups' => $this->getDatabase()->product_groups()->orderBy('name', 'COLLATE NOCASE'),
'userfields' => $this->getUserfieldsService()->GetFields('products'),
@ -182,7 +182,7 @@ class StockController extends BaseController
'barcodes' => $this->getDatabase()->product_barcodes()->orderBy('barcode'),
'quantityunits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'),
'quantityunitsStock' => $this->getDatabase()->quantity_units()->where('id IN (SELECT to_qu_id FROM quantity_unit_conversions_resolved WHERE product_id = :1) OR NOT EXISTS(SELECT 1 FROM stock_log WHERE product_id = :1)', $product->id)->orderBy('name', 'COLLATE NOCASE'),
'quantityunitsPurchase' => $this->getDatabase()->quantity_units()->where('id IN (SELECT to_qu_id FROM quantity_unit_conversions_resolved WHERE product_id = :1)', $product->id)->orderBy('name', 'COLLATE NOCASE'),
'referencedQuantityunits' => $this->getDatabase()->quantity_units()->where('id IN (SELECT to_qu_id FROM quantity_unit_conversions_resolved WHERE product_id = :1)', $product->id)->orderBy('name', 'COLLATE NOCASE'),
'shoppinglocations' => $this->getDatabase()->shopping_locations()->orderBy('name', 'COLLATE NOCASE'),
'productgroups' => $this->getDatabase()->product_groups()->orderBy('name', 'COLLATE NOCASE'),
'userfields' => $this->getUserfieldsService()->GetFields('products'),

View File

@ -4732,10 +4732,13 @@
"product_barcodes": {
"$ref": "#/components/schemas/ProductBarcode"
},
"quantity_unit_stock": {
"$ref": "#/components/schemas/QuantityUnit"
},
"default_quantity_unit_purchase": {
"$ref": "#/components/schemas/QuantityUnit"
},
"quantity_unit_stock": {
"default_quantity_unit_consume": {
"$ref": "#/components/schemas/QuantityUnit"
},
"last_purchased": {

View File

@ -2344,3 +2344,9 @@ msgstr ""
msgid "Product specifc QU conversions"
msgstr ""
msgid "Default quantity unit consume"
msgstr ""
msgid "This is the default quantity unit used when consuming this product"
msgstr ""

135
migrations/0210.sql Normal file
View File

@ -0,0 +1,135 @@
ALTER TABLE products
ADD qu_id_consume INTEGER;
UPDATE products
SET qu_id_consume = qu_id_stock;
CREATE TRIGGER default_qu_id_consume AFTER INSERT ON products
BEGIN
UPDATE products
SET qu_id_consume = qu_id_stock
WHERE id = NEW.id
AND IFNULL(qu_id_consume, 0) = 0;
END;
DROP TRIGGER default_qu_conversion;
CREATE TRIGGER default_qu_conversions AFTER INSERT ON products
BEGIN
/*
Automatically create a default (product specific 1 to 1) QU conversion when a product
with qu_stock != qu_purchase was created and when no default QU conversion applies
*/
INSERT INTO quantity_unit_conversions
(from_qu_id, to_qu_id, factor, product_id)
SELECT p.qu_id_purchase, p.qu_id_stock, 1, p.id
FROM products p
WHERE p.id = NEW.id
AND p.qu_id_stock != qu_id_purchase
AND NOT EXISTS(SELECT 1 FROM quantity_unit_conversions_resolved WHERE product_id = p.id AND from_qu_id = p.qu_id_stock AND to_qu_id = p.qu_id_purchase);
/*
Automatically create a default (product specific 1 to 1) QU conversion when a product
with qu_stock != qu_consume was created and when no default QU conversion applies
*/
INSERT INTO quantity_unit_conversions
(from_qu_id, to_qu_id, factor, product_id)
SELECT p.qu_id_consume, p.qu_id_stock, 1, p.id
FROM products p
WHERE p.id = NEW.id
AND p.qu_id_stock != qu_id_consume
AND NOT EXISTS(SELECT 1 FROM quantity_unit_conversions_resolved WHERE product_id = p.id AND from_qu_id = p.qu_id_stock AND to_qu_id = p.qu_id_consume);
END;
DROP VIEW products_view;
CREATE VIEW products_view
AS
SELECT
p.*,
CASE WHEN (SELECT 1 FROM products WHERE parent_product_id = p.id) NOTNULL THEN 1 ELSE 0 END AS has_sub_products,
IFNULL(quc_purchase.factor, 1.0) AS qu_factor_purchase_to_stock,
IFNULL(quc_consume.factor, 1.0) AS qu_factor_consume_to_stock
FROM products p
LEFT JOIN quantity_unit_conversions_resolved quc_purchase
ON p.id = quc_purchase.product_id
AND p.qu_id_purchase = quc_purchase.from_qu_id
AND p.qu_id_stock = quc_purchase.to_qu_id
LEFT JOIN quantity_unit_conversions_resolved quc_consume
ON p.id = quc_consume.product_id
AND p.qu_id_consume = quc_consume.from_qu_id
AND p.qu_id_stock = quc_consume.to_qu_id;
DROP VIEW uihelper_stock_current_overview;
CREATE VIEW uihelper_stock_current_overview
AS
SELECT
p.id,
sc.amount_opened AS amount_opened,
p.tare_weight AS tare_weight,
p.enable_tare_weight_handling AS enable_tare_weight_handling,
sc.amount AS amount,
sc.value as value,
sc.product_id AS product_id,
sc.best_before_date AS best_before_date,
EXISTS(SELECT id FROM stock_missing_products WHERE id = sc.product_id) AS product_missing,
p.name AS product_name,
pg.name AS product_group_name,
EXISTS(SELECT * FROM shopping_list WHERE shopping_list.product_id = sc.product_id) AS on_shopping_list,
qu_stock.name AS qu_stock_name,
qu_stock.name_plural AS qu_stock_name_plural,
qu_purchase.name AS qu_purchase_name,
qu_purchase.name_plural AS qu_purchase_name_plural,
qu_consume.name AS qu_consume_name,
qu_consume.name_plural AS qu_consume_name_plural,
sc.is_aggregated_amount,
sc.amount_opened_aggregated,
sc.amount_aggregated,
p.calories AS product_calories,
sc.amount * p.calories AS calories,
sc.amount_aggregated * p.calories AS calories_aggregated,
p.quick_consume_amount,
p.quick_consume_amount / p.qu_factor_consume_to_stock AS quick_consume_amount_qu_consume,
p.due_type,
plp.purchased_date AS last_purchased,
plp.price AS last_price,
pap.price as average_price,
p.min_stock_amount,
pbcs.barcodes AS product_barcodes,
p.description AS product_description,
l.name AS product_default_location_name,
p_parent.id AS parent_product_id,
p_parent.name AS parent_product_name,
p.picture_file_name AS product_picture_file_name,
p.no_own_stock AS product_no_own_stock,
p.qu_factor_purchase_to_stock AS product_qu_factor_purchase_to_stock
FROM (
SELECT *
FROM stock_current
WHERE best_before_date IS NOT NULL
UNION
SELECT m.id, 0, 0, 0, null, 0, 0, 0, p.due_type
FROM stock_missing_products m
JOIN products p
ON m.id = p.id
WHERE m.id NOT IN (SELECT product_id FROM stock_current)
) sc
JOIN products_view p
ON sc.product_id = p.id
JOIN locations l
ON p.location_id = l.id
JOIN quantity_units qu_stock
ON p.qu_id_stock = qu_stock.id
JOIN quantity_units qu_purchase
ON p.qu_id_purchase = qu_purchase.id
JOIN quantity_units qu_consume
ON p.qu_id_consume = qu_consume.id
LEFT JOIN product_groups pg
ON p.product_group_id = pg.id
LEFT JOIN products_last_purchased plp
ON sc.product_id = plp.product_id
LEFT JOIN products_average_price pap
ON sc.product_id = pap.product_id
LEFT JOIN product_barcodes_comma_separated pbcs
ON sc.product_id = pbcs.product_id
LEFT JOIN products p_parent
ON p.parent_product_id = p_parent.id
WHERE p.hide_on_stock_overview = 0;

View File

@ -106,7 +106,7 @@
$("#display_amount").removeAttr("max");
if (BoolVal(Grocy.UserSettings.stock_default_consume_amount_use_quick_consume_amount))
{
$('#display_amount').val(productDetails.product.quick_consume_amount);
$('#display_amount').val(productDetails.product.quick_consume_amount * $("#qu_id option:selected").attr("data-qu-factor"));
}
else
{
@ -196,7 +196,7 @@ $('#save-mark-as-open-button').on('click', function(e)
if (BoolVal(Grocy.UserSettings.stock_default_consume_amount_use_quick_consume_amount))
{
$('#display_amount').val(productDetails.product.quick_consume_amount);
$('#display_amount').val(productDetails.product.quick_consume_amount * $("#qu_id option:selected").attr("data-qu-factor"));
}
else
{
@ -365,10 +365,10 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
current_productDetails = productDetails;
Grocy.Components.ProductAmountPicker.Reload(productDetails.product.id, productDetails.quantity_unit_stock.id);
Grocy.Components.ProductAmountPicker.SetQuantityUnit(productDetails.quantity_unit_stock.id);
Grocy.Components.ProductAmountPicker.SetQuantityUnit(productDetails.default_quantity_unit_consume.id);
if (BoolVal(Grocy.UserSettings.stock_default_consume_amount_use_quick_consume_amount))
{
$('#display_amount').val(productDetails.product.quick_consume_amount);
$('#display_amount').val(productDetails.product.quick_consume_amount * $("#qu_id option:selected").attr("data-qu-factor"));
}
else
{

View File

@ -358,15 +358,22 @@ $(document).on('click', '.barcode-delete-button', function(e)
$('#qu_id_stock').change(function(e)
{
// Preset QU purchase with stock QU if unset
// Preset qu_id_purchase and qu_id_consume by qu_id_stock if unset
var quIdStock = $('#qu_id_stock');
var quIdPurchase = $('#qu_id_purchase');
var quIdConsume = $('#qu_id_consume');
if (quIdPurchase[0].selectedIndex === 0 && quIdStock[0].selectedIndex !== 0)
{
quIdPurchase[0].selectedIndex = quIdStock[0].selectedIndex;
Grocy.FrontendHelpers.ValidateForm('product-form');
}
if (quIdConsume[0].selectedIndex === 0 && quIdStock[0].selectedIndex !== 0)
{
quIdConsume[0].selectedIndex = quIdStock[0].selectedIndex;
}
Grocy.FrontendHelpers.ValidateForm('product-form');
});
$(window).on("message", function(e)

View File

@ -747,6 +747,7 @@ class StockService extends BaseService
$nextDueDate = $this->getDatabase()->stock()->where('product_id', $productId)->min('best_before_date');
$quPurchase = $this->getDatabase()->quantity_units($product->qu_id_purchase);
$quStock = $this->getDatabase()->quantity_units($product->qu_id_stock);
$quConsume = $this->getDatabase()->quantity_units($product->qu_id_consume);
$location = $this->getDatabase()->locations($product->location_id);
$averageShelfLifeDays = intval($this->getDatabase()->stock_average_product_shelf_life()->where('id', $productId)->fetch()->average_shelf_life_days);
$currentPrice = $this->getDatabase()->products_current_price()->where('product_id', $productId)->fetch()->price;
@ -785,8 +786,9 @@ class StockService extends BaseService
'stock_amount_opened' => $stockCurrentRow->amount_opened,
'stock_amount_aggregated' => $stockCurrentRow->amount_aggregated,
'stock_amount_opened_aggregated' => $stockCurrentRow->amount_opened_aggregated,
'default_quantity_unit_purchase' => $quPurchase,
'quantity_unit_stock' => $quStock,
'default_quantity_unit_purchase' => $quPurchase,
'default_quantity_unit_consume' => $quConsume,
'last_price' => $lastPrice,
'avg_price' => $avgPrice,
'oldest_price' => $currentPrice, // Deprecated

View File

@ -395,7 +395,7 @@
id="qu_id_purchase"
name="qu_id_purchase">
<option></option>
@foreach($quantityunitsPurchase as $quantityunit)
@foreach($referencedQuantityunits as $quantityunit)
<option @if($mode=='edit'
&&
$quantityunit->id == $product->qu_id_purchase) selected="selected" @endif value="{{ $quantityunit->id }}">{{ $quantityunit->name }}</option>
@ -404,6 +404,26 @@
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
</div>
<div class="form-group">
<label for="qu_id_consume">{{ $__t('Default quantity unit consume') }}</label>
<i class="fa-solid fa-question-circle text-muted"
data-toggle="tooltip"
data-trigger="hover click"
title="{{ $__t('This is the default quantity unit used when consuming this product') }}"></i>
<select required
class="custom-control custom-select input-group-qu"
id="qu_id_consume"
name="qu_id_consume">
<option></option>
@foreach($referencedQuantityunits as $quantityunit)
<option @if($mode=='edit'
&&
$quantityunit->id == $product->qu_id_consume) selected="selected" @endif value="{{ $quantityunit->id }}">{{ $quantityunit->name }}</option>
@endforeach
</select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
</div>
<div class="form-group mb-1">
<div class="custom-control custom-checkbox">
<input @if($mode=='edit'

View File

@ -192,12 +192,12 @@
href="#"
data-toggle="tooltip"
data-placement="left"
title="{{ $__t('Consume %1$s of %2$s', floatval($currentStockEntry->quick_consume_amount) . ' ' . $currentStockEntry->qu_unit_name, $currentStockEntry->product_name) }}"
title="{{ $__t('Consume %1$s of %2$s', floatval($currentStockEntry->quick_consume_amount_qu_consume) . ' ' . $currentStockEntry->qu_consume_name, $currentStockEntry->product_name) }}"
data-product-id="{{ $currentStockEntry->product_id }}"
data-product-name="{{ $currentStockEntry->product_name }}"
data-product-qu-name="{{ $currentStockEntry->qu_unit_name }}"
data-product-qu-name="{{ $currentStockEntry->qu_stock_name }}"
data-consume-amount="{{ $currentStockEntry->quick_consume_amount }}">
<i class="fa-solid fa-utensils"></i> <span class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->quick_consume_amount }}</span>
<i class="fa-solid fa-utensils"></i> <span class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->quick_consume_amount_qu_consume }}</span>
</a>
<a id="product-{{ $currentStockEntry->product_id }}-consume-all-button"
class="permission-STOCK_CONSUME btn btn-danger btn-sm product-consume-button @if($currentStockEntry->amount_aggregated == 0) disabled @endif"
@ -207,7 +207,7 @@
title="{{ $__t('Consume all %s which are currently in stock', $currentStockEntry->product_name) }}"
data-product-id="{{ $currentStockEntry->product_id }}"
data-product-name="{{ $currentStockEntry->product_name }}"
data-product-qu-name="{{ $currentStockEntry->qu_unit_name }}"
data-product-qu-name="{{ $currentStockEntry->qu_stock_name }}"
data-consume-amount="@if($currentStockEntry->enable_tare_weight_handling == 1){{$currentStockEntry->tare_weight}}@else{{$currentStockEntry->amount}}@endif"
data-original-total-stock-amount="{{$currentStockEntry->amount}}">
<i class="fa-solid fa-utensils"></i> {{ $__t('All') }}
@ -217,12 +217,12 @@
href="#"
data-toggle="tooltip"
data-placement="left"
title="{{ $__t('Mark %1$s of %2$s as open', floatval($currentStockEntry->quick_consume_amount) . ' ' . $currentStockEntry->qu_unit_name, $currentStockEntry->product_name) }}"
title="{{ $__t('Mark %1$s of %2$s as open', floatval($currentStockEntry->quick_consume_amount_qu_consume) . ' ' . $currentStockEntry->qu_consume_name, $currentStockEntry->product_name) }}"
data-product-id="{{ $currentStockEntry->product_id }}"
data-product-name="{{ $currentStockEntry->product_name }}"
data-product-qu-name="{{ $currentStockEntry->qu_unit_name }}"
data-product-qu-name="{{ $currentStockEntry->qu_stock_name }}"
data-open-amount="{{ $currentStockEntry->quick_consume_amount }}">
<i class="fa-solid fa-box-open"></i> <span class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->quick_consume_amount }}</span>
<i class="fa-solid fa-box-open"></i> <span class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->quick_consume_amount_qu_consume }}</span>
</a>
@endif
<div class="dropdown d-inline-block">
@ -326,14 +326,14 @@
<span class="custom-sort d-none">@if($currentStockEntry->product_no_own_stock == 1){{ $currentStockEntry->amount_aggregated }}@else{{ $currentStockEntry->amount }}@endif</span>
<span class="@if($currentStockEntry->product_no_own_stock == 1) d-none @endif">
<span id="product-{{ $currentStockEntry->product_id }}-amount"
class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->amount }}</span> <span id="product-{{ $currentStockEntry->product_id }}-qu-name">{{ $__n($currentStockEntry->amount, $currentStockEntry->qu_unit_name, $currentStockEntry->qu_unit_name_plural) }}</span>
class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->amount }}</span> <span id="product-{{ $currentStockEntry->product_id }}-qu-name">{{ $__n($currentStockEntry->amount, $currentStockEntry->qu_stock_name, $currentStockEntry->qu_stock_name_plural) }}</span>
<span id="product-{{ $currentStockEntry->product_id }}-opened-amount"
class="small font-italic">@if($currentStockEntry->amount_opened > 0){{ $__t('%s opened', $currentStockEntry->amount_opened) }}@endif</span>
</span>
@if($currentStockEntry->is_aggregated_amount == 1)
<span class="@if($currentStockEntry->product_no_own_stock == 0) pl-1 @endif text-secondary">
<i class="fa-solid 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, $currentStockEntry->qu_unit_name, $currentStockEntry->qu_unit_name_plural, true) }}
class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->amount_aggregated }}</span> {{ $__n($currentStockEntry->amount_aggregated, $currentStockEntry->qu_stock_name, $currentStockEntry->qu_stock_name_plural, true) }}
@if($currentStockEntry->amount_opened_aggregated > 0)
<span id="product-{{ $currentStockEntry->product_id }}-opened-amount-aggregated"
class="small font-italic">
@ -407,8 +407,8 @@
<span data-toggle="tooltip"
data-trigger="hover click"
data-html="true"
title="{!! $__t('%1$s per %2$s', '<span class=\'locale-number locale-number-currency\'>' . $currentStockEntry->last_price . '</span>', $currentStockEntry->qu_unit_name) !!}">
{!! $__t('%1$s per %2$s', '<span class="locale-number locale-number-currency">' . floatval($currentStockEntry->last_price) * floatval($currentStockEntry->product_qu_factor_purchase_to_stock) . '</span>', $currentStockEntry->qu_purchase_unit_name) !!}
title="{!! $__t('%1$s per %2$s', '<span class=\'locale-number locale-number-currency\'>' . $currentStockEntry->last_price . '</span>', $currentStockEntry->qu_stock_name) !!}">
{!! $__t('%1$s per %2$s', '<span class="locale-number locale-number-currency">' . floatval($currentStockEntry->last_price) * floatval($currentStockEntry->product_qu_factor_purchase_to_stock) . '</span>', $currentStockEntry->qu_purchase_name) !!}
</span>
@endif
</td>
@ -437,8 +437,8 @@
<span data-toggle="tooltip"
data-trigger="hover click"
data-html="true"
title="{!! $__t('%1$s per %2$s', '<span class=\'locale-number locale-number-currency\'>' . $currentStockEntry->average_price . '</span>', $currentStockEntry->qu_unit_name) !!}">
{!! $__t('%1$s per %2$s', '<span class="locale-number locale-number-currency">' . floatval($currentStockEntry->average_price) * floatval($currentStockEntry->product_qu_factor_purchase_to_stock) . '</span>', $currentStockEntry->qu_purchase_unit_name) !!}
title="{!! $__t('%1$s per %2$s', '<span class=\'locale-number locale-number-currency\'>' . $currentStockEntry->average_price . '</span>', $currentStockEntry->qu_stock_name) !!}">
{!! $__t('%1$s per %2$s', '<span class="locale-number locale-number-currency">' . floatval($currentStockEntry->average_price) * floatval($currentStockEntry->product_qu_factor_purchase_to_stock) . '</span>', $currentStockEntry->qu_purchase_name) !!}
</span>
@endif
</td>