Squashed commit

Fixed recipe ingredient costs/calories calculation when having different QUs and when only_check_single_unit_in_stock is set (fixes #2529)
Added a new column "Product picture" on /products (closes #2640)
Fixed partly opening stock entries stock_id handling (fixes #2391)
This commit is contained in:
Bernd Bestel 2025-01-13 17:41:08 +01:00
parent e7cea3d949
commit f4d5f21832
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
13 changed files with 188 additions and 29 deletions

View File

@ -19,7 +19,9 @@
- => Quick video demo (using a Barcode Laser Scanner): https://www.youtube.com/watch?v=-moXPA-VvGc - => Quick video demo (using a Barcode Laser Scanner): https://www.youtube.com/watch?v=-moXPA-VvGc
- => Quick video demo (using Browser Camera Barcode Scanning): https://www.youtube.com/watch?v=veezFX4X1JU - => Quick video demo (using Browser Camera Barcode Scanning): https://www.youtube.com/watch?v=veezFX4X1JU
- Optimized that when moving a product to a freezer location (so when freezing it) the due date will no longer be replaced when the product option "Default due days after freezing" is set to `0` - Optimized that when moving a product to a freezer location (so when freezing it) the due date will no longer be replaced when the product option "Default due days after freezing" is set to `0`
- Added a new column "Product picture" on the products list (master data) page (hidden by default)
- Fixed that a once set quantity unit on a product barcode could not be removed on edit - Fixed that a once set quantity unit on a product barcode could not be removed on edit
- Fixed that when consuming a specific stock entry which is opened, and which originated from a before partly opened stock entry, the unopened one was wrongly consume instead
### Shopping list ### Shopping list
@ -27,7 +29,7 @@
### Recipes ### Recipes
- xxx - Fixed that calories/costs of recipe ingredients were wrong when the ingredient option "Only check if any amount is in stock" was set and the on the ingredient used quantity unit was different from the product's QU stock
### Meal plan ### Meal plan

View File

@ -2,10 +2,7 @@
// This is executed inside DatabaseMigrationService class/context // This is executed inside DatabaseMigrationService class/context
use Grocy\Services\LocalizationService;
$localizationService = $this->getLocalizationService(); $localizationService = $this->getLocalizationService();
$db = $this->getDatabaseService()->GetDbConnection(); $db = $this->getDatabaseService()->GetDbConnection();
if ($db->quantity_units()->count() === 0) if ($db->quantity_units()->count() === 0)

View File

@ -2,10 +2,7 @@
// This is executed inside DatabaseMigrationService class/context // This is executed inside DatabaseMigrationService class/context
use Grocy\Services\LocalizationService;
$localizationService = $this->getLocalizationService(); $localizationService = $this->getLocalizationService();
$db = $this->getDatabaseService()->GetDbConnection(); $db = $this->getDatabaseService()->GetDbConnection();
$defaultShoppingList = $db->shopping_lists()->where('id = 1')->fetch(); $defaultShoppingList = $db->shopping_lists()->where('id = 1')->fetch();

View File

@ -2,6 +2,4 @@
// This is executed inside DatabaseMigrationService class/context // This is executed inside DatabaseMigrationService class/context
use Grocy\Services\StockService;
$this->getStockService()->CompactStockEntries(); $this->getStockService()->CompactStockEntries();

135
migrations/0241.sql Normal file
View File

@ -0,0 +1,135 @@
DROP VIEW recipes_pos_resolved;
CREATE VIEW recipes_pos_resolved
AS
-- Multiplication by 1.0 to force conversion to float (REAL)
SELECT
r.id AS recipe_id,
rp.id AS recipe_pos_id,
rp.product_id AS product_id,
CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) ELSE rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) * ((rnr.includes_servings*1.0) / (rnrr.base_servings*1.0)) END AS recipe_amount,
IFNULL(sc.amount_aggregated, 0) AS stock_amount,
CASE WHEN IFNULL(sc.amount_aggregated, 0) >= CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 0.00000001 ELSE CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) ELSE rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) * ((rnr.includes_servings*1.0) / (rnrr.base_servings*1.0)) END END THEN 1 ELSE 0 END AS need_fulfilled,
CASE WHEN IFNULL(sc.amount_aggregated, 0) - CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 0.00000001 ELSE CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) ELSE rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) * ((rnr.includes_servings*1.0) / (rnrr.base_servings*1.0)) END END < 0 THEN ABS(IFNULL(sc.amount_aggregated, 0) - (CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) ELSE rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) * ((rnr.includes_servings*1.0) / (rnrr.base_servings*1.0)) END)) ELSE 0 END AS missing_amount,
IFNULL(sl.amount, 0) AS amount_on_shopping_list,
CASE WHEN ROUND(IFNULL(sc.amount_aggregated, 0) + CASE WHEN r.not_check_shoppinglist = 1 THEN 0 ELSE IFNULL(sl.amount, 0) END, 2) >= ROUND(CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 0.00000001 ELSE CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) ELSE rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) * ((rnr.includes_servings*1.0) / (rnrr.base_servings*1.0)) END END, 2) THEN 1 ELSE 0 END AS need_fulfilled_with_shopping_list,
rp.qu_id,
(r.desired_servings*1.0 / r.base_servings*1.0) * CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN IFNULL(qucr.factor, 1.0) ELSE 1 END * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) * rp.amount * IFNULL(pcp.price, 0) * rp.price_factor * CASE WHEN rp.product_id != p_effective.id THEN IFNULL(qucr.factor, 1.0) ELSE 1.0 END AS costs,
CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN 0 ELSE 1 END AS is_nested_recipe_pos,
rp.ingredient_group,
pg.name as product_group,
rp.id, -- Just a dummy id column
r.type as recipe_type,
rnr.includes_recipe_id as child_recipe_id,
rp.note,
rp.variable_amount AS recipe_variable_amount,
rp.only_check_single_unit_in_stock,
rp.amount * CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN IFNULL(qucr.factor, 1.0) ELSE 1 END / r.base_servings*1.0 * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) * IFNULL(p_effective.calories, 0) * CASE WHEN rp.product_id != p_effective.id THEN IFNULL(qucr.factor, 1.0) ELSE 1.0 END AS calories,
p.active AS product_active,
CASE pvs.current_due_status
WHEN 'ok' THEN 0
WHEN 'due_soon' THEN 1
WHEN 'overdue' THEN 10
WHEN 'expired' THEN 20
END AS due_score,
IFNULL(pcs.product_id_effective, rp.product_id) AS product_id_effective,
p.name AS product_name
FROM recipes r
JOIN recipes_nestings_resolved rnr
ON r.id = rnr.recipe_id
JOIN recipes rnrr
ON rnr.includes_recipe_id = rnrr.id
JOIN recipes_pos rp
ON rnr.includes_recipe_id = rp.recipe_id
JOIN products p
ON rp.product_id = p.id
JOIN products_volatile_status pvs
ON rp.product_id = pvs.product_id
LEFT JOIN product_groups pg
ON p.product_group_id = pg.id
LEFT JOIN (
SELECT product_id, SUM(amount) AS amount
FROM shopping_list
GROUP BY product_id) sl
ON rp.product_id = sl.product_id
LEFT JOIN stock_current sc
ON rp.product_id = sc.product_id
LEFT JOIN products_current_substitutions pcs
ON rp.product_id = pcs.parent_product_id
LEFT JOIN products_current_price pcp
ON IFNULL(pcs.product_id_effective, rp.product_id) = pcp.product_id
LEFT JOIN products p_effective
ON IFNULL(pcs.product_id_effective, rp.product_id) = p_effective.id
LEFT JOIN cache__quantity_unit_conversions_resolved qucr
ON IFNULL(pcs.product_id_effective, rp.product_id) = qucr.product_id
AND CASE WHEN rp.product_id != p_effective.id THEN p.qu_id_stock ELSE rp.qu_id END = qucr.from_qu_id
AND IFNULL(p_effective.qu_id_stock, p.qu_id_stock) = qucr.to_qu_id
WHERE rp.not_check_stock_fulfillment = 0
UNION
-- Just add all recipe positions which should not be checked against stock with fulfilled need
SELECT
r.id AS recipe_id,
rp.id AS recipe_pos_id,
rp.product_id AS product_id,
CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) ELSE rp.amount * ((r.desired_servings*1.0) / (r.base_servings*1.0)) * ((rnr.includes_servings*1.0) / (rnrr.base_servings*1.0)) END AS recipe_amount,
IFNULL(sc.amount_aggregated, 0) AS stock_amount,
1 AS need_fulfilled,
0 AS missing_amount,
IFNULL(sl.amount, 0) AS amount_on_shopping_list,
1 AS need_fulfilled_with_shopping_list,
rp.qu_id,
(r.desired_servings*1.0 / r.base_servings*1.0) * CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN IFNULL(qucr.factor, 1.0) ELSE 1 END * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) * rp.amount * IFNULL(pcp.price, 0) * rp.price_factor * CASE WHEN rp.product_id != p_effective.id THEN IFNULL(qucr.factor, 1.0) ELSE 1.0 END AS costs,
CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN 0 ELSE 1 END AS is_nested_recipe_pos,
rp.ingredient_group,
pg.name as product_group,
rp.id, -- Just a dummy id column
r.type as recipe_type,
rnr.includes_recipe_id as child_recipe_id,
rp.note,
rp.variable_amount AS recipe_variable_amount,
rp.only_check_single_unit_in_stock,
rp.amount * CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN IFNULL(qucr.factor, 1.0) ELSE 1 END / r.base_servings*1.0 * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) * IFNULL(p_effective.calories, 0) * CASE WHEN rp.product_id != p_effective.id THEN IFNULL(qucr.factor, 1.0) ELSE 1.0 END AS calories,
p.active AS product_active,
CASE pvs.current_due_status
WHEN 'ok' THEN 0
WHEN 'due_soon' THEN 1
WHEN 'overdue' THEN 10
WHEN 'expired' THEN 20
END AS due_score,
IFNULL(pcs.product_id_effective, rp.product_id) AS product_id_effective,
p.name AS product_name
FROM recipes r
JOIN recipes_nestings_resolved rnr
ON r.id = rnr.recipe_id
JOIN recipes rnrr
ON rnr.includes_recipe_id = rnrr.id
JOIN recipes_pos rp
ON rnr.includes_recipe_id = rp.recipe_id
JOIN products p
ON rp.product_id = p.id
JOIN products_volatile_status pvs
ON rp.product_id = pvs.product_id
LEFT JOIN product_groups pg
ON p.product_group_id = pg.id
LEFT JOIN (
SELECT product_id, SUM(amount) AS amount
FROM shopping_list
GROUP BY product_id) sl
ON rp.product_id = sl.product_id
LEFT JOIN stock_current sc
ON rp.product_id = sc.product_id
LEFT JOIN products_current_substitutions pcs
ON rp.product_id = pcs.parent_product_id
LEFT JOIN products_current_price pcp
ON IFNULL(pcs.product_id_effective, rp.product_id) = pcp.product_id
LEFT JOIN products p_effective
ON IFNULL(pcs.product_id_effective, rp.product_id) = p_effective.id
LEFT JOIN cache__quantity_unit_conversions_resolved qucr
ON IFNULL(pcs.product_id_effective, rp.product_id) = qucr.product_id
AND CASE WHEN rp.product_id != p_effective.id THEN p.qu_id_stock ELSE rp.qu_id END = qucr.from_qu_id
AND IFNULL(p_effective.qu_id_stock, p.qu_id_stock) = qucr.to_qu_id
WHERE rp.not_check_stock_fulfillment = 1;

22
migrations/0242.php Normal file
View File

@ -0,0 +1,22 @@
<?php
// This is executed inside DatabaseMigrationService class/context
// Assign a new stock_id to all opened stock entries where there is also an unopened one with the same stock_id
$db = $this->getDatabaseService();
$sql = 'SELECT s1.id
FROM stock s1
WHERE IFNULL(s1.open, 0) = 1
AND EXISTS (
SELECT 1
FROM stock s2
WHERE s2.stock_id = s1.stock_id
AND IFNULL(s2.open, 0) = 0
)';
$rows = $db->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ);
foreach ($rows as $row)
{
$db->ExecuteDbStatement('UPDATE stock SET stock_id = \'' . uniqid() . '\' WHERE id = ' . $row->id);
}

View File

@ -16,14 +16,14 @@ body {
font-size: 0.8em; font-size: 0.8em;
} }
a:not(.btn):not(.nav-link):not(.dropdown-item) { a:not(.btn):not(.nav-link):not(.dropdown-item):not(.list-group-item) {
color: inherit; color: inherit;
text-decoration: underline; text-decoration: underline;
text-decoration-style: dotted; text-decoration-style: dotted;
text-underline-offset: 0.2rem; text-underline-offset: 0.2rem;
} }
a:not(.btn):not(.nav-link):not(.dropdown-item):hover { a:not(.btn):not(.nav-link):not(.dropdown-item):not(.list-group-item):hover {
text-decoration: underline; text-decoration: underline;
} }

View File

@ -26,7 +26,8 @@ body.night-mode,
color: #c1c1c1; color: #c1c1c1;
} }
.night-mode .table { .night-mode .table,
.night-mode .list-group-item {
color: #c1c1c1; color: #c1c1c1;
} }

View File

@ -226,7 +226,6 @@ var sumValue = 0;
$("#location_id").on('change', function(e) $("#location_id").on('change', function(e)
{ {
var locationId = $(e.target).val(); var locationId = $(e.target).val();
$("#specific_stock_entry").find("option").remove().end().append("<option></option>"); $("#specific_stock_entry").find("option").remove().end().append("<option></option>");
if ($("#use_specific_stock_entry").is(":checked")) if ($("#use_specific_stock_entry").is(":checked"))
{ {
@ -293,21 +292,19 @@ function OnLocationChange(locationId, stockId)
if (stockEntry.location_id == locationId) if (stockEntry.location_id == locationId)
{ {
if ($("#specific_stock_entry option[value='" + stockEntry.stock_id + "']").length == 0) var noteTxt = "";
if (stockEntry.note)
{ {
var noteTxt = ""; noteTxt = " " + stockEntry.note;
if (stockEntry.note)
{
noteTxt = " " + stockEntry.note;
}
$("#specific_stock_entry").append($("<option>", {
value: stockEntry.stock_id,
amount: stockEntry.amount,
text: __t("Amount: %1$s; Due on %2$s; Bought on %3$s", stockEntry.amount, moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD")) + "; " + openTxt + noteTxt
}));
} }
$("#specific_stock_entry").append($("<option>", {
"value": stockEntry.stock_id,
"text": __t("Amount: %1$s; Due on %2$s; Bought on %3$s", stockEntry.amount, moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD")) + "; " + openTxt + noteTxt,
"data-amount": stockEntry.amount,
"data-id": stockEntry.id
}));
sumValue = sumValue + (stockEntry.amount || 0); sumValue = sumValue + (stockEntry.amount || 0);
if (stockEntry.stock_id == stockId) if (stockEntry.stock_id == stockId)
@ -585,7 +582,7 @@ $("#specific_stock_entry").on("change", function(e)
} }
else else
{ {
$("#display_amount").attr("max", Number.parseFloat($('option:selected', this).attr('amount')).toFixed(Grocy.UserSettings.stock_decimal_places_amounts)); $("#display_amount").attr("max", Number.parseFloat($('option:selected', this).attr('data-amount')).toFixed(Grocy.UserSettings.stock_decimal_places_amounts));
} }
}); });
@ -603,7 +600,6 @@ $("#use_specific_stock_entry").on("change", function()
$("#specific_stock_entry").attr("disabled", ""); $("#specific_stock_entry").attr("disabled", "");
$("#specific_stock_entry").removeAttr("required"); $("#specific_stock_entry").removeAttr("required");
$("#specific_stock_entry").val(""); $("#specific_stock_entry").val("");
$("#location_id").trigger('change');
} }
Grocy.FrontendHelpers.ValidateForm("consume-form"); Grocy.FrontendHelpers.ValidateForm("consume-form");

View File

@ -5,6 +5,7 @@
{ 'searchable': false, "targets": 0 }, { 'searchable': false, "targets": 0 },
{ 'visible': false, 'targets': 7 }, { 'visible': false, 'targets': 7 },
{ 'visible': false, 'targets': 8 }, { 'visible': false, 'targets': 8 },
{ 'visible': false, 'targets': 9 },
{ "type": "html-num-fmt", "targets": 3 } { "type": "html-num-fmt", "targets": 3 }
].concat($.fn.dataTable.defaults.columnDefs) ].concat($.fn.dataTable.defaults.columnDefs)
}); });

View File

@ -748,4 +748,7 @@ if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_LABEL_PRINTER)
}); });
} }
Grocy.Components.UserfieldsForm.Load(); if (Grocy.Components.UserfieldsForm)
{
Grocy.Components.UserfieldsForm.Load();
}

View File

@ -1069,7 +1069,7 @@ class StockService extends BaseService
'purchased_date' => $stockEntry->purchased_date, 'purchased_date' => $stockEntry->purchased_date,
'location_id' => $stockEntry->location_id, 'location_id' => $stockEntry->location_id,
'shopping_location_id' => $stockEntry->shopping_location_id, 'shopping_location_id' => $stockEntry->shopping_location_id,
'stock_id' => $stockEntry->stock_id, 'stock_id' => uniqid(),
'price' => $stockEntry->price, 'price' => $stockEntry->price,
'note' => $stockEntry->note 'note' => $stockEntry->note
]); ]);

View File

@ -128,6 +128,7 @@
<th class="">{{ $__t('Product group') }}</th> <th class="">{{ $__t('Product group') }}</th>
<th class="@if(!GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) d-none @endif allow-grouping">{{ $__t('Default store') }}</th> <th class="@if(!GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) d-none @endif allow-grouping">{{ $__t('Default store') }}</th>
<th class="">{{ $__t('Grocycode') }}</th> <th class="">{{ $__t('Grocycode') }}</th>
<th>{{ $__t('Product picture') }}</th>
@include('components.userfields_thead', array( @include('components.userfields_thead', array(
'userfields' => $userfields 'userfields' => $userfields
@ -215,6 +216,12 @@
<img src="{{ $U('/product/' . $product->id . '/grocycode?size=25') }}" <img src="{{ $U('/product/' . $product->id . '/grocycode?size=25') }}"
loading="lazy"> loading="lazy">
</td> </td>
<td>
@if(!empty($product->picture_file_name))
<img src="{{ $U('/api/files/productpictures/' . base64_encode($product->picture_file_name) . '?force_serve_as=picture&best_fit_width=64&best_fit_height=64') }}"
loading="lazy">
@endif
</td>
@include('components.userfields_tbody', array( @include('components.userfields_tbody', array(
'userfields' => $userfields, 'userfields' => $userfields,