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 ### 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) - Optimized the line plot markers color of the price history chart (product card) (thanks @DeepCoreSystem)
- External barcode lookup plugin optimizations: - 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) - 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') if ($args['productId'] == 'new')
{ {
$quantityunits = $this->getDatabase()->quantity_units()->where('active = 1')->orderBy('name', 'COLLATE NOCASE');
return $this->renderPage($response, 'productform', [ return $this->renderPage($response, 'productform', [
'locations' => $this->getDatabase()->locations()->where('active = 1')->orderBy('name'), 'locations' => $this->getDatabase()->locations()->where('active = 1')->orderBy('name'),
'barcodes' => $this->getDatabase()->product_barcodes()->orderBy('barcode'), 'barcodes' => $this->getDatabase()->product_barcodes()->orderBy('barcode'),
'quantityunits' => $this->getDatabase()->quantity_units()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'), 'quantityunitsAll' => $quantityunits,
'quantityunitsStock' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'), 'quantityunitsReferenced' => $quantityunits,
'referencedQuantityunits' => $this->getDatabase()->quantity_units()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'shoppinglocations' => $this->getDatabase()->shopping_locations()->where('active = 1')->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'), 'productgroups' => $this->getDatabase()->product_groups()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'userfields' => $this->getUserfieldsService()->GetFields('products'), 'userfields' => $this->getUserfieldsService()->GetFields('products'),
@ -217,9 +218,8 @@ class StockController extends BaseController
'product' => $product, 'product' => $product,
'locations' => $this->getDatabase()->locations()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'), 'locations' => $this->getDatabase()->locations()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'barcodes' => $this->getDatabase()->product_barcodes()->orderBy('barcode'), 'barcodes' => $this->getDatabase()->product_barcodes()->orderBy('barcode'),
'quantityunits' => $this->getDatabase()->quantity_units()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'), 'quantityunitsAll' => $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'), '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'),
'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'),
'shoppinglocations' => $this->getDatabase()->shopping_locations()->where('active = 1')->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'), 'productgroups' => $this->getDatabase()->product_groups()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'userfields' => $this->getUserfieldsService()->GetFields('products'), 'userfields' => $this->getUserfieldsService()->GetFields('products'),

View File

@ -2470,3 +2470,6 @@ msgstr ""
msgid "List actions" msgid "List actions"
msgstr "" 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) $('#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 quIdStock = $('#qu_id_stock');
var quIdPurchase = $('#qu_id_purchase'); var quIdPurchase = $('#qu_id_purchase');
var quIdConsume = $('#qu_id_consume'); var quIdConsume = $('#qu_id_consume');
var quIdPrice = $('#qu_id_price'); 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; 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; 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; quIdPrice[0].selectedIndex = quIdStock[0].selectedIndex;
} }
quIdStockBefore = quIdStock.val();
Grocy.FrontendHelpers.ValidateForm('product-form'); Grocy.FrontendHelpers.ValidateForm('product-form');
}); });

View File

@ -371,15 +371,19 @@
<div class="form-group"> <div class="form-group">
<label for="qu_id_stock">{{ $__t('Quantity unit stock') }}</label> <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 <select required
class="custom-control custom-select input-group-qu" class="custom-control custom-select input-group-qu"
id="qu_id_stock" id="qu_id_stock"
name="qu_id_stock"> name="qu_id_stock">
<option></option> <option></option>
@foreach($quantityunitsStock as $quantityunit) @foreach($quantityunitsReferenced as $qu)
<option @if($mode=='edit' <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 @endforeach
</select> </select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div> <div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
@ -396,10 +400,10 @@
id="qu_id_purchase" id="qu_id_purchase"
name="qu_id_purchase"> name="qu_id_purchase">
<option></option> <option></option>
@foreach($referencedQuantityunits as $quantityunit) @foreach($quantityunitsReferenced as $qu)
<option @if($mode=='edit' <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 @endforeach
</select> </select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div> <div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
@ -416,10 +420,10 @@
id="qu_id_consume" id="qu_id_consume"
name="qu_id_consume"> name="qu_id_consume">
<option></option> <option></option>
@foreach($referencedQuantityunits as $quantityunit) @foreach($quantityunitsReferenced as $qu)
<option @if($mode=='edit' <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 @endforeach
</select> </select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div> <div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
@ -436,10 +440,10 @@
id="qu_id_price" id="qu_id_price"
name="qu_id_price"> name="qu_id_price">
<option></option> <option></option>
@foreach($referencedQuantityunits as $quantityunit) @foreach($quantityunitsReferenced as $qu)
<option @if($mode=='edit' <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 @endforeach
</select> </select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div> <div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
@ -762,7 +766,7 @@
</td> </td>
<td> <td>
@if(!empty($barcode->qu_id)) @if(!empty($barcode->qu_id))
{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', $barcode->qu_id)->name }} {{ FindObjectInArrayByPropertyValue($quantityunitsAll, 'id', $barcode->qu_id)->name }}
@endif @endif
</td> </td>
<td> <td>
@ -886,16 +890,16 @@
</a> </a>
</td> </td>
<td> <td>
{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->from_qu_id)->name }} {{ FindObjectInArrayByPropertyValue($quantityunitsAll, 'id', $quConversion->from_qu_id)->name }}
</td> </td>
<td> <td>
{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->to_qu_id)->name }} {{ FindObjectInArrayByPropertyValue($quantityunitsAll, 'id', $quConversion->to_qu_id)->name }}
</td> </td>
<td> <td>
<span class="locale-number locale-number-quantity-amount">{{ $quConversion->factor }}</span> <span class="locale-number locale-number-quantity-amount">{{ $quConversion->factor }}</span>
</td> </td>
<td class="font-italic"> <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> </td>
</tr> </tr>
@endforeach @endforeach