Optimized product definition quantity unit handling

This commit is contained in:
Bernd Bestel 2026-02-04 21:49:46 +01:00
parent cf7df4bdf8
commit 487631397c
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
6 changed files with 104 additions and 22 deletions

View File

@ -10,6 +10,11 @@
### Stock
- Optimized product definition quantity unit handling:
- As long as a product was not once in stock, the product options "Default quantity unit purchase", "Default quantity unit consume" and "Quantity unit for prices" can now be changed to any other unit
- When necessary (means when no default quantity unit conversions apply), "1:1" product specific quantity unit conversion between the product's QU stock and the corresponding other unit will now be created automatically after editing a product (like already done when initially creating a product with different unit definitions)
- For convenience, when changing a product's QU stock and when all other unit properties ("Default quantity unit purchase" etc.) are the same, they will now be changed in tandem (like already done when creating a new product and initially setting the product's QU stock)
- (This should drastically improve the workflow of completing a product setup looked up via an external barcode lookup plugin)
- Optimized the line plot markers color of the price history chart (product card) (thanks @DeepCoreSystem)
- External barcode lookup plugin optimizations:
- When an image URL without a file extension is returned, the file extension is now determined by the Content-Type header (if any) (thanks @jordy-u for the idea)

View File

@ -195,12 +195,13 @@ class StockController extends BaseController
{
if ($args['productId'] == 'new')
{
$quantityunits = $this->getDatabase()->quantity_units()->where('active = 1')->orderBy('name', 'COLLATE NOCASE');
return $this->renderPage($response, 'productform', [
'locations' => $this->getDatabase()->locations()->where('active = 1')->orderBy('name'),
'barcodes' => $this->getDatabase()->product_barcodes()->orderBy('barcode'),
'quantityunits' => $this->getDatabase()->quantity_units()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'quantityunitsStock' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'),
'referencedQuantityunits' => $this->getDatabase()->quantity_units()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'quantityunitsAll' => $quantityunits,
'quantityunitsReferenced' => $quantityunits,
'shoppinglocations' => $this->getDatabase()->shopping_locations()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'productgroups' => $this->getDatabase()->product_groups()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'userfields' => $this->getUserfieldsService()->GetFields('products'),
@ -217,9 +218,8 @@ class StockController extends BaseController
'product' => $product,
'locations' => $this->getDatabase()->locations()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'barcodes' => $this->getDatabase()->product_barcodes()->orderBy('barcode'),
'quantityunits' => $this->getDatabase()->quantity_units()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'quantityunitsStock' => $this->getDatabase()->quantity_units()->where('id IN (SELECT to_qu_id FROM cache__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'),
'referencedQuantityunits' => $this->getDatabase()->quantity_units()->where('active = 1')->where('id IN (SELECT to_qu_id FROM cache__quantity_unit_conversions_resolved WHERE product_id = :1)', $product->id)->orderBy('name', 'COLLATE NOCASE'),
'quantityunitsAll' => $this->getDatabase()->quantity_units()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'quantityunitsReferenced' => $this->getDatabase()->quantity_units()->where('id IN (SELECT to_qu_id FROM cache__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'),
'shoppinglocations' => $this->getDatabase()->shopping_locations()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'productgroups' => $this->getDatabase()->product_groups()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'userfields' => $this->getUserfieldsService()->GetFields('products'),

View File

@ -2470,3 +2470,6 @@ msgstr ""
msgid "List actions"
msgstr ""
msgid "After this product was once in stock and when the desired quantity unit cannot be selected here, first create a corresponding unit conversion"
msgstr ""

66
migrations/0255.sql Normal file
View File

@ -0,0 +1,66 @@
DROP TRIGGER products_default_qu_conversions;
CREATE TRIGGER products_default_qu_conversions_INS AFTER INSERT ON products
BEGIN
-- Create product specific 1:1 conversions when QU stock != QU purchase/consume/price
-- and when no default QU conversion apply
-- with qu_id_stock != qu_id_purchase
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);
-- with qu_id_stock != qu_id_consume
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);
-- with qu_id_stock != qu_id_price
INSERT INTO quantity_unit_conversions
(from_qu_id, to_qu_id, factor, product_id)
SELECT p.qu_id_price, p.qu_id_stock, 1, p.id
FROM products p
WHERE p.id = NEW.id
AND p.qu_id_stock != qu_id_price
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_price);
END;
CREATE TRIGGER products_default_qu_conversions_UPD AFTER UPDATE ON products
BEGIN
-- Create product specific 1:1 conversions when QU stock != QU purchase/consume/price
-- and when no default QU conversion apply
-- with qu_id_stock != qu_id_purchase
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);
-- with qu_id_stock != qu_id_consume
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);
-- with qu_id_stock != qu_id_price
INSERT INTO quantity_unit_conversions
(from_qu_id, to_qu_id, factor, product_id)
SELECT p.qu_id_price, p.qu_id_stock, 1, p.id
FROM products p
WHERE p.id = NEW.id
AND p.qu_id_stock != qu_id_price
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_price);
END;

View File

@ -352,29 +352,33 @@ $(document).on('click', '.barcode-delete-button', function(e)
});
});
var quIdStockBefore = $("#qu_id_stock").val();
$('#qu_id_stock').change(function(e)
{
// Preset qu_id_purchase/qu_id_consume/qu_id_price by qu_id_stock if unset
// Preset qu_id_purchase / qu_id_consume / qu_id_price by qu_id_stock if unset or identical
var quIdStock = $('#qu_id_stock');
var quIdPurchase = $('#qu_id_purchase');
var quIdConsume = $('#qu_id_consume');
var quIdPrice = $('#qu_id_price');
if (quIdPurchase[0].selectedIndex === 0 && quIdStock[0].selectedIndex !== 0)
if (quIdPurchase[0].selectedIndex === 0 && quIdStock[0].selectedIndex !== 0 || quIdStockBefore == quIdPurchase.val())
{
quIdPurchase[0].selectedIndex = quIdStock[0].selectedIndex;
}
if (quIdConsume[0].selectedIndex === 0 && quIdStock[0].selectedIndex !== 0)
if (quIdConsume[0].selectedIndex === 0 && quIdStock[0].selectedIndex !== 0 || quIdStockBefore == quIdConsume.val())
{
quIdConsume[0].selectedIndex = quIdStock[0].selectedIndex;
}
if (quIdPrice[0].selectedIndex === 0 && quIdStock[0].selectedIndex !== 0)
if (quIdPrice[0].selectedIndex === 0 && quIdStock[0].selectedIndex !== 0 || quIdStockBefore == quIdPrice.val())
{
quIdPrice[0].selectedIndex = quIdStock[0].selectedIndex;
}
quIdStockBefore = quIdStock.val();
Grocy.FrontendHelpers.ValidateForm('product-form');
});

View File

@ -371,15 +371,19 @@
<div class="form-group">
<label for="qu_id_stock">{{ $__t('Quantity unit stock') }}</label>
<i class="fa-solid fa-question-circle text-muted"
data-toggle="tooltip"
data-trigger="hover click"
title="{{ $__t('After this product was once in stock and when the desired quantity unit cannot be selected here, first create a corresponding unit conversion') }}"></i>
<select required
class="custom-control custom-select input-group-qu"
id="qu_id_stock"
name="qu_id_stock">
<option></option>
@foreach($quantityunitsStock as $quantityunit)
@foreach($quantityunitsReferenced as $qu)
<option @if($mode=='edit'
&&
$quantityunit->id == $product->qu_id_stock) selected="selected" @endif value="{{ $quantityunit->id }}" data-plural-form="{{ $quantityunit->name_plural }}">{{ $quantityunit->name }}</option>
$qu->id == $product->qu_id_stock) selected="selected" @endif value="{{ $qu->id }}" data-plural-form="{{ $qu->name_plural }}">{{ $qu->name }}</option>
@endforeach
</select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
@ -396,10 +400,10 @@
id="qu_id_purchase"
name="qu_id_purchase">
<option></option>
@foreach($referencedQuantityunits as $quantityunit)
@foreach($quantityunitsReferenced as $qu)
<option @if($mode=='edit'
&&
$quantityunit->id == $product->qu_id_purchase) selected="selected" @endif value="{{ $quantityunit->id }}">{{ $quantityunit->name }}</option>
$qu->id == $product->qu_id_purchase) selected="selected" @endif value="{{ $qu->id }}">{{ $qu->name }}</option>
@endforeach
</select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
@ -416,10 +420,10 @@
id="qu_id_consume"
name="qu_id_consume">
<option></option>
@foreach($referencedQuantityunits as $quantityunit)
@foreach($quantityunitsReferenced as $qu)
<option @if($mode=='edit'
&&
$quantityunit->id == $product->qu_id_consume) selected="selected" @endif value="{{ $quantityunit->id }}">{{ $quantityunit->name }}</option>
$qu->id == $product->qu_id_consume) selected="selected" @endif value="{{ $qu->id }}">{{ $qu->name }}</option>
@endforeach
</select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
@ -436,10 +440,10 @@
id="qu_id_price"
name="qu_id_price">
<option></option>
@foreach($referencedQuantityunits as $quantityunit)
@foreach($quantityunitsReferenced as $qu)
<option @if($mode=='edit'
&&
$quantityunit->id == $product->qu_id_price) selected="selected" @endif value="{{ $quantityunit->id }}">{{ $quantityunit->name }}</option>
$qu->id == $product->qu_id_price) selected="selected" @endif value="{{ $qu->id }}">{{ $qu->name }}</option>
@endforeach
</select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
@ -762,7 +766,7 @@
</td>
<td>
@if(!empty($barcode->qu_id))
{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', $barcode->qu_id)->name }}
{{ FindObjectInArrayByPropertyValue($quantityunitsAll, 'id', $barcode->qu_id)->name }}
@endif
</td>
<td>
@ -886,16 +890,16 @@
</a>
</td>
<td>
{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->from_qu_id)->name }}
{{ FindObjectInArrayByPropertyValue($quantityunitsAll, 'id', $quConversion->from_qu_id)->name }}
</td>
<td>
{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->to_qu_id)->name }}
{{ FindObjectInArrayByPropertyValue($quantityunitsAll, 'id', $quConversion->to_qu_id)->name }}
</td>
<td>
<span class="locale-number locale-number-quantity-amount">{{ $quConversion->factor }}</span>
</td>
<td class="font-italic">
{!! $__t('This means 1 %1$s is the same as %2$s %3$s', FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->from_qu_id)->name, '<span class="locale-number locale-number-quantity-amount">' . $quConversion->factor . '</span>', $__n($quConversion->factor, FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->to_qu_id)->name, FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->to_qu_id)->name_plural, true)) !!}
{!! $__t('This means 1 %1$s is the same as %2$s %3$s', FindObjectInArrayByPropertyValue($quantityunitsAll, 'id', $quConversion->from_qu_id)->name, '<span class="locale-number locale-number-quantity-amount">' . $quConversion->factor . '</span>', $__n($quConversion->factor, FindObjectInArrayByPropertyValue($quantityunitsAll, 'id', $quConversion->to_qu_id)->name, FindObjectInArrayByPropertyValue($quantityunitsAll, 'id', $quConversion->to_qu_id)->name_plural, true)) !!}
</td>
</tr>
@endforeach