Added a dialog to show product related resolved QU conversions (references #2056 and #1360)

This commit is contained in:
Bernd Bestel 2022-12-04 19:02:15 +01:00
parent d889e9d3ad
commit 50fac692ad
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
8 changed files with 368 additions and 9 deletions

View File

@ -123,7 +123,6 @@ class StockController extends BaseController
public function ProductBarcodesEditForm(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) public function ProductBarcodesEditForm(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{ {
$product = null; $product = null;
if (isset($request->getQueryParams()['product'])) if (isset($request->getQueryParams()['product']))
{ {
$product = $this->getDatabase()->products($request->getQueryParams()['product']); $product = $this->getDatabase()->products($request->getQueryParams()['product']);
@ -272,7 +271,6 @@ class StockController extends BaseController
public function QuantityUnitConversionEditForm(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) public function QuantityUnitConversionEditForm(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{ {
$product = null; $product = null;
if (isset($request->getQueryParams()['product'])) if (isset($request->getQueryParams()['product']))
{ {
$product = $this->getDatabase()->products($request->getQueryParams()['product']); $product = $this->getDatabase()->products($request->getQueryParams()['product']);
@ -549,4 +547,24 @@ class StockController extends BaseController
'transactionTypes' => GetClassConstants('\Grocy\Services\StockService', 'TRANSACTION_TYPE_') 'transactionTypes' => GetClassConstants('\Grocy\Services\StockService', 'TRANSACTION_TYPE_')
]); ]);
} }
public function QuantityUnitConversionsResolved(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
$product = null;
if (isset($request->getQueryParams()['product']))
{
$product = $this->getDatabase()->products($request->getQueryParams()['product']);
$quantityUnitConversionsResolved = $this->getDatabase()->quantity_unit_conversions_resolved()->where('product_id', $product->id);
}
else
{
$quantityUnitConversionsResolved = $this->getDatabase()->quantity_unit_conversions_resolved()->where('product_id IS NULL');
}
return $this->renderPage($response, 'quantityunitconversionsresolved', [
'product' => $product,
'quantityUnits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'),
'quantityUnitConversionsResolved' => $quantityUnitConversionsResolved
]);
}
} }

View File

@ -2341,3 +2341,12 @@ msgstr ""
msgid "Clear done items" msgid "Clear done items"
msgstr "" msgstr ""
msgid "This shows all to this product directly or indirectly related quantity units and their derived conversion factors"
msgstr ""
msgid "Show resolved conversions"
msgstr ""
msgid "QU conversions resolved"
msgstr ""

View File

@ -1,5 +1,4 @@
DROP VIEW quantity_unit_conversions_resolved; DROP VIEW quantity_unit_conversions_resolved;
CREATE VIEW quantity_unit_conversions_resolved CREATE VIEW quantity_unit_conversions_resolved
AS AS
@ -150,7 +149,7 @@ AS (
-- Fourth case: Add the default unit conversions that are reachable by a given product. -- Fourth case: Add the default unit conversions that are reachable by a given product.
-- We cannot start with them directly, as we only want to add default conversions, -- We cannot start with them directly, as we only want to add default conversions,
-- where at least one of the units is 'reachable' from the product's storage quantity unit. -- where at least one of the units is 'reachable' from the product's stock quantity unit.
-- Thus we add these cases here. -- Thus we add these cases here.
SELECT DISTINCT SELECT DISTINCT
1, c.product_id, 1, c.product_id,

182
migrations/0204.sql Normal file
View File

@ -0,0 +1,182 @@
DROP VIEW quantity_unit_conversions_resolved;
CREATE VIEW quantity_unit_conversions_resolved
AS
/*
First, determine conversions that are a single step.
There may be multiple definitions for conversions between two units
(e.g. due to purchase-to-stock, product-specific and default conversions),
thus priorities are used to disambiguate conversions.
Later, we'll only use the factor with the highest priority to convert between two units.
*/
WITH RECURSIVE conversion_factors_dup(product_id, from_qu_id, to_qu_id, factor, priority)
AS (
-- Priority 1: Product "purchase to stock" factors ...
SELECT
id,
qu_id_purchase,
qu_id_stock,
qu_factor_purchase_to_stock,
40
FROM products
WHERE qu_id_stock != qu_id_purchase
UNION -- ... and the inverse factors
SELECT
id,
qu_id_stock,
qu_id_purchase,
1.0 / qu_factor_purchase_to_stock,
40
FROM products
WHERE qu_id_stock != qu_id_purchase
UNION
-- Priority 2: Product specific QU overrides
-- Note that the quantity_unit_conversions table already contains both conversion directions for every conversion.
SELECT
product_id,
from_qu_id,
to_qu_id,
factor,
30
FROM quantity_unit_conversions
WHERE product_id IS NOT NULL
UNION
-- Priority 3: Default QU conversions are handled in a later CTE, as we can't determine yet, for which products they are applicable.
SELECT
product_id,
from_qu_id,
to_qu_id,
factor,
20
FROM quantity_unit_conversions
WHERE product_id IS NULL
UNION
-- Priority 4: QU conversions with a factor of 1.0 from the stock unit to the stock unit
SELECT
id,
qu_id_stock,
qu_id_stock,
1.0,
10
FROM products
),
-- Now, remove duplicate conversions, only retaining the entries with the highest priority
conversion_factors(product_id, from_qu_id, to_qu_id, factor)
AS (
SELECT
product_id,
from_qu_id,
to_qu_id,
FIRST_VALUE(factor) OVER win
FROM conversion_factors_dup
GROUP BY product_id, from_qu_id, to_qu_id
WINDOW win AS(PARTITION BY product_id, from_qu_id, to_qu_id ORDER BY priority DESC)
),
-- Now build the closure of posisble conversions using a recursive CTE
closure(depth, product_id, from_qu_id, to_qu_id, factor, path)
AS (
-- As a base case, select the conversions that refer to a concrete product
SELECT
1 as depth,
product_id,
from_qu_id,
to_qu_id,
factor,
'/' || from_qu_id || '/' || to_qu_id || '/' -- We need to keep track of the conversion path in order to prevent cycles
FROM conversion_factors
WHERE product_id IS NOT NULL
UNION
-- First recursive case: Add a product-associated conversion to the chain
SELECT
c.depth + 1,
c.product_id,
c.from_qu_id,
s.to_qu_id,
c.factor * s.factor,
c.path || s.to_qu_id || '/'
FROM closure c
JOIN conversion_factors s
ON c.product_id = s.product_id
AND c.to_qu_id = s.from_qu_id
WHERE c.path NOT LIKE ('%/' || s.to_qu_id || '/%') -- Prevent cycles
UNION
-- Second recursive case: Add a default unit conversion to the *start* of the conversion chain
SELECT
c.depth + 1,
c.product_id,
s.from_qu_id,
c.to_qu_id,
s.factor * c.factor,
'/' || s.from_qu_id || c.path
FROM closure c
JOIN conversion_factors s
ON s.to_qu_id = c.from_qu_id
AND s.product_id IS NULL
WHERE NOT EXISTS(SELECT 1 FROM conversion_factors ci WHERE ci.product_id = c.product_id AND ci.from_qu_id = s.from_qu_id AND ci.to_qu_id = s.to_qu_id) -- Do this only, if there is no product_specific conversion between the units in s
AND c.path NOT LIKE ('%/' || s.from_qu_id || '/%') -- Prevent cycles
UNION
-- Third recursive case: Add a default unit conversion to the *end* of the conversion chain
SELECT
c.depth + 1,
c.product_id,
c.from_qu_id,
s.to_qu_id,
c.factor * s.factor,
c.path || s.to_qu_id || '/'
FROM closure c
JOIN conversion_factors s
ON c.to_qu_id = s.from_qu_id
AND s.product_id IS NULL
WHERE NOT EXISTS(SELECT 1 FROM conversion_factors ci WHERE ci.product_id = c.product_id AND ci.from_qu_id = s.from_qu_id AND ci.to_qu_id = s.to_qu_id) -- Do this only, if there is no product_specific conversion between the units in s
AND c.path NOT LIKE ('%/' || s.to_qu_id || '/%') -- Prevent cycles
UNION
-- Fourth case: Add the default unit conversions that are reachable by a given product.
-- We cannot start with them directly, as we only want to add default conversions,
-- where at least one of the units is 'reachable' from the product's stock quantity unit.
-- Thus we add these cases here.
SELECT DISTINCT
1, c.product_id,
s.from_qu_id, s.to_qu_id,
s.factor,
'/' || s.from_qu_id || '/' || s.to_qu_id || '/'
FROM closure c, conversion_factors s
WHERE NOT EXISTS(SELECT 1 FROM conversion_factors ci WHERE ci.product_id = c.product_id AND ci.from_qu_id = s.from_qu_id AND ci.to_qu_id = s.to_qu_id)
AND c.path LIKE ('%/' || s.from_qu_id || '/' || s.to_qu_id || '/%') -- Prevent cycles
)
SELECT DISTINCT
-1 AS id, -- Dummy, LessQL needs an id column
c.product_id,
c.from_qu_id,
qu_from.name AS from_qu_name,
qu_from.name_plural AS from_qu_name_plural,
c.to_qu_id,
qu_to.name AS to_qu_name,
qu_to.name_plural AS to_qu_name_plural,
FIRST_VALUE(factor) OVER win AS factor,
FIRST_VALUE(c.path) OVER win AS path
FROM closure c
JOIN quantity_units qu_from
ON c.from_qu_id = qu_from.id
JOIN quantity_units qu_to
ON c.to_qu_id = qu_to.id
GROUP BY product_id, from_qu_id, to_qu_id
WINDOW win AS (PARTITION BY product_id, from_qu_id, to_qu_id ORDER BY depth ASC)
ORDER BY product_id, from_qu_id, to_qu_id;

View File

@ -0,0 +1,39 @@
var quConversionsResolvedTable = $('#qu-conversions-resolved-table').DataTable({
'order': [[1, 'asc']],
'columnDefs': [
{ 'orderable': false, 'targets': 0 },
{ 'searchable': false, "targets": 0 }
].concat($.fn.dataTable.defaults.columnDefs)
});
$('#qu-conversions-resolved-table tbody').removeClass("d-none");
quConversionsResolvedTable.columns.adjust().draw();
$("#search").on("keyup", Delay(function()
{
var value = $(this).val();
if (value === "all")
{
value = "";
}
quConversionsResolvedTable.search(value).draw();
}, 200));
$("#quantity-unit-filter").on("change", function()
{
var value = $("#quantity-unit-filter option:selected").text();
if (value === __t("All"))
{
value = "";
}
quConversionsResolvedTable.column([quConversionsResolvedTable.colReorder.transpose(1), quConversionsResolvedTable.colReorder.transpose(2)]).search(value).draw();
});
$("#clear-filter-button").on("click", function()
{
$("#search").val("");
$("#quantity-unit-filter").val("all");
quConversionsResolvedTable.column([quConversionsResolvedTable.colReorder.transpose(1), quConversionsResolvedTable.colReorder.transpose(2)]).search("").draw();
quConversionsResolvedTable.search("").draw();
});

View File

@ -62,6 +62,7 @@ $app->group('', function (RouteCollectorProxy $group) {
$group->get('/productbarcodes/{productBarcodeId}', '\Grocy\Controllers\StockController:ProductBarcodesEditForm'); $group->get('/productbarcodes/{productBarcodeId}', '\Grocy\Controllers\StockController:ProductBarcodesEditForm');
$group->get('/stockentry/{entryId}/grocycode', '\Grocy\Controllers\StockController:StockEntryGrocycodeImage'); $group->get('/stockentry/{entryId}/grocycode', '\Grocy\Controllers\StockController:StockEntryGrocycodeImage');
$group->get('/stockentry/{entryId}/label', '\Grocy\Controllers\StockController:StockEntryGrocycodeLabel'); $group->get('/stockentry/{entryId}/label', '\Grocy\Controllers\StockController:StockEntryGrocycodeLabel');
$group->get('/quantityunitconversionsresolved', '\Grocy\Controllers\StockController:QuantityUnitConversionsResolved');
} }
// Stock price tracking // Stock price tracking

View File

@ -579,7 +579,7 @@
@if($mode == "edit") @if($mode == "edit")
<div class="related-links collapse d-md-flex order-2 width-xs-sm-100" <div class="related-links collapse d-md-flex order-2 width-xs-sm-100"
id="related-links"> id="related-links">
<a class="btn btn-outline-primary btn-sm m-1 mt-md-0 mb-md-0 float-right show-as-dialog-link" <a class="btn btn-primary btn-sm m-1 mt-md-0 mb-md-0 float-right show-as-dialog-link"
href="{{ $U('/productbarcodes/new?embedded&product=' . $product->id ) }}"> href="{{ $U('/productbarcodes/new?embedded&product=' . $product->id ) }}">
{{ $__t('Add') }} {{ $__t('Add') }}
</a> </a>
@ -717,10 +717,17 @@
@if($mode == "edit") @if($mode == "edit")
<div class="related-links collapse d-md-flex order-2 width-xs-sm-100" <div class="related-links collapse d-md-flex order-2 width-xs-sm-100"
id="related-links"> id="related-links">
<a class="btn btn-outline-primary btn-sm m-1 mt-md-0 mb-md-0 float-right show-as-dialog-link" <a class="btn btn-primary btn-sm m-1 mt-md-0 mb-md-0 float-right show-as-dialog-link"
href="{{ $U('/quantityunitconversion/new?embedded&product=' . $product->id ) }}"> href="{{ $U('/quantityunitconversion/new?embedded&product=' . $product->id ) }}">
{{ $__t('Add') }} {{ $__t('Add') }}
</a> </a>
<a class="btn btn-outline-primary btn-sm m-1 mt-md-0 mb-md-0 float-right show-as-dialog-link"
href="{{ $U('/quantityunitconversionsresolved?embedded&product=' . $product->id ) }}"
data-toggle="tooltip"
data-trigger="hover click"
title="{{ $__t('This shows all to this product directly or indirectly related quantity units and their derived conversion factors') }}">
{{ $__t('Show resolved conversions') }}
</a>
</div> </div>
@endif @endif
</div> </div>

View File

@ -0,0 +1,104 @@
@extends('layout.default')
@section('title', $__t('QU conversions resolved'))
@section('viewJsName', 'quantityunitconversionsresolved')
@section('content')
<div class="row">
<div class="col">
<div class="title-related-links">
<h2 class="title">@yield('title')</h2>
@if($product != null)
<h2>
<span class="text-muted small">{{ $__t('Product') }} <strong>{{ $product->name }}</strong></span>
</h2>
@endif
</div>
</div>
</div>
<hr class="my-2">
<div class="row d-md-flex"
id="table-filter-row">
<div class="col-6">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa-solid fa-search"></i></span>
</div>
<input type="text"
id="search"
class="form-control"
placeholder="{{ $__t('Search') }}">
</div>
</div>
<div class="col-5">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa-solid fa-filter"></i>&nbsp;{{ $__t('Quantity unit') }}</span>
</div>
<select class="custom-control custom-select"
id="quantity-unit-filter">
<option value="all">{{ $__t('All') }}</option>
@foreach($quantityUnits as $quantityUnit)
<option value="{{ $quantityUnit->id }}">{{ $quantityUnit->name }}</option>
@endforeach
</select>
</div>
</div>
<div class="col-1">
<div class="float-right">
<button id="clear-filter-button"
class="btn btn-sm btn-outline-info"
data-toggle="tooltip"
title="{{ $__t('Clear filter') }}">
<i class="fa-solid fa-filter-circle-xmark"></i>
</button>
</div>
</div>
</div>
<div class="row">
<div class="col">
<table id="qu-conversions-resolved-table"
class="table table-sm table-striped nowrap w-100">
<thead>
<tr>
<th class="border-right"><a class="text-muted change-table-columns-visibility-button"
data-toggle="tooltip"
title="{{ $__t('Table options') }}"
data-table-selector="#qu-conversions-resolved-table"
href="#"><i class="fa-solid fa-eye"></i></a>
</th>
<th class="allow-grouping">{{ $__t('Quantity unit from') }}</th>
<th class="allow-grouping">{{ $__t('Quantity unit to') }}</th>
<th>{{ $__t('Factor') }}</th>
<th></th>
</tr>
</thead>
<tbody class="d-none">
@foreach($quantityUnitConversionsResolved as $quConversion)
<tr>
<td class="fit-content border-right"></td>
<td>
{{ FindObjectInArrayByPropertyValue($quantityUnits, 'id', $quConversion->from_qu_id)->name }}
</td>
<td>
{{ FindObjectInArrayByPropertyValue($quantityUnits, '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)) !!}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@stop