diff --git a/changelog/66_UNRELEASED_xxxx-xx-xx.md b/changelog/66_UNRELEASED_xxxx-xx-xx.md index aa8a5dda..0d669349 100644 --- a/changelog/66_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/66_UNRELEASED_xxxx-xx-xx.md @@ -1,6 +1,9 @@ - Stock entry labels get now also printed on inventory (only when adding products, same option "Stock entry label" like on the purchase page) - Added a separate status filter and table row highlighting (blue) on the chores, tasks and batteries overview pages for items due today - Additionally, the "due soon" days of chores/tasks/batteries (top right corner settings menu) can be set to `0` to disable that filter/highlighting +- The `config.php` option `FEATURE_SETTING_STOCK_COUNT_OPENED_PRODUCTS_AGAINST_MINIMUM_STOCK_AMOUNT` was removed and is now a new product option `Treat opened as out of stock`, means, if opened items will be counted as missing for calculating if a product is below its minimum stock amount, can now be configured per product + - The existing option will be migrated to all existing products, so no changed behavior after the update + - There is also a new stock setting (section "Presets for new products") which can be used to configure the default when adding products (also that will be set based on the old setting on migration) - Optimized relative time display (also fixed a phrasing problem for some languages, e.g. Hungarian) (thanks @Tallyrald) - When using LDAP authentication, the configured `LDAP_UID_ATTR` is now used to compare if the user already exists instead of the username entered on the login page (that prevents creating multiple users if you entere the username in different notations) (thanks @FloSet) - When using reverse proxy authentication (`ReverseProxyAuthMiddleware`), it's now also possible to pass the username in an environment variable instead of an HTTP header (new `config.php` option `REVERSE_PROXY_AUTH_USE_ENV`) (thanks @Forceu) diff --git a/config-dist.php b/config-dist.php index 892e2a63..1dd97cac 100644 --- a/config-dist.php +++ b/config-dist.php @@ -149,6 +149,7 @@ DefaultUserSetting('product_presets_location_id', -1); // Default location id fo DefaultUserSetting('product_presets_product_group_id', -1); // Default product group id for new products (-1 means no product group is preset) DefaultUserSetting('product_presets_qu_id', -1); // Default quantity unit id for new products (-1 means no quantity unit is preset) DefaultUserSetting('product_presets_default_due_days', 0); // Default due days for new products (-1 means that the product will be never overdue) +DefaultUserSetting('product_presets_treat_opened_as_out_of_stock', true); // Default "Treat opened as out of stock" option for new products DefaultUserSetting('stock_decimal_places_amounts', 4); // Default decimal places allowed for amounts DefaultUserSetting('stock_decimal_places_prices', 2); // Default decimal places allowed for prices DefaultUserSetting('stock_auto_decimal_separator_prices', false); @@ -221,5 +222,4 @@ Setting('FEATURE_FLAG_CHORES_ASSIGNMENTS', true); Setting('FEATURE_FLAG_THERMAL_PRINTER', false); // Feature settings -Setting('FEATURE_SETTING_STOCK_COUNT_OPENED_PRODUCTS_AGAINST_MINIMUM_STOCK_AMOUNT', true); // When set to true, opened items will be counted as missing for calculating if a product is below its minimum stock amount Setting('FEATURE_FLAG_AUTO_TORCH_ON_WITH_CAMERA', true); // Enables the torch automatically (if the device has one) diff --git a/localization/strings.pot b/localization/strings.pot index 1a655b48..5731ea56 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2286,3 +2286,9 @@ msgstr "" msgid "Save & add another task" msgstr "" + +msgid "Treat opened as out of stock" +msgstr "" + +msgid "When enabled, opened items will be counted as missing for calculating if this product is below its minimum stock amount" +msgstr "" diff --git a/migrations/0161.sql b/migrations/0161.sql new file mode 100644 index 00000000..07cf453f --- /dev/null +++ b/migrations/0161.sql @@ -0,0 +1,132 @@ +ALTER TABLE products +ADD treat_opened_as_out_of_stock TINYINT NOT NULL DEFAULT 1 CHECK(treat_opened_as_out_of_stock IN (0, 1)); + +DROP VIEW stock_missing_products_including_opened; + +DROP VIEW stock_missing_products; +CREATE VIEW stock_missing_products +AS + +-- Products WITHOUT sub products where the amount of the sub products SHOULD NOT be cumulated +SELECT + p.id, + MAX(p.name) AS name, + p.min_stock_amount - IFNULL(SUM(s.amount), 0) - CASE WHEN p.treat_opened_as_out_of_stock = 1 THEN IFNULL(SUM(s.amount_opened), 0) ELSE 0 END AS amount_missing, + CASE WHEN IFNULL(SUM(s.amount), 0) > 0 THEN 1 ELSE 0 END AS is_partly_in_stock +FROM products_view p +LEFT JOIN stock_current s + ON p.id = s.product_id +WHERE p.min_stock_amount != 0 + AND p.cumulate_min_stock_amount_of_sub_products = 0 + AND p.has_sub_products = 0 + AND p.parent_product_id IS NULL + AND IFNULL(p.active, 0) = 1 +GROUP BY p.id +HAVING IFNULL(SUM(s.amount), 0) - CASE WHEN p.treat_opened_as_out_of_stock = 1 THEN IFNULL(SUM(s.amount_opened), 0) ELSE 0 END < p.min_stock_amount + +UNION + +-- Parent products WITH sub products where the amount of the sub products SHOULD be cumulated +SELECT + p.id, + MAX(p.name) AS name, + SUM(sub_p.min_stock_amount) - IFNULL(SUM(s.amount_aggregated), 0) - CASE WHEN p.treat_opened_as_out_of_stock = 1 THEN IFNULL(SUM(s.amount_opened_aggregated), 0) ELSE 0 END AS amount_missing, + CASE WHEN IFNULL(SUM(s.amount), 0) > 0 THEN 1 ELSE 0 END AS is_partly_in_stock +FROM products_view p +JOIN products_resolved pr + ON p.id = pr.parent_product_id +JOIN products sub_p + ON pr.sub_product_id = sub_p.id +LEFT JOIN stock_current s + ON pr.sub_product_id = s.product_id +WHERE sub_p.min_stock_amount != 0 + AND p.cumulate_min_stock_amount_of_sub_products = 1 + AND IFNULL(p.active, 0) = 1 +GROUP BY p.id +HAVING IFNULL(SUM(s.amount_aggregated), 0) - CASE WHEN p.treat_opened_as_out_of_stock = 1 THEN IFNULL(SUM(s.amount_opened_aggregated), 0) ELSE 0 END < SUM(sub_p.min_stock_amount) + +UNION + +-- Sub products where the amount SHOULD NOT be cumulated into the parent product +SELECT + sub_p.id, + MAX(sub_p.name) AS name, + SUM(sub_p.min_stock_amount) - IFNULL(SUM(s.amount_aggregated), 0) - CASE WHEN p.treat_opened_as_out_of_stock = 1 THEN IFNULL(SUM(s.amount_opened_aggregated), 0) ELSE 0 END AS amount_missing, + CASE WHEN IFNULL(SUM(s.amount), 0) > 0 THEN 1 ELSE 0 END AS is_partly_in_stock +FROM products p +JOIN products_resolved pr + ON p.id = pr.parent_product_id +JOIN products sub_p + ON pr.sub_product_id = sub_p.id +LEFT JOIN stock_current s + ON pr.sub_product_id = s.product_id +WHERE sub_p.min_stock_amount != 0 + AND p.cumulate_min_stock_amount_of_sub_products = 0 + AND IFNULL(p.active, 0) = 1 +GROUP BY sub_p.id +HAVING IFNULL(SUM(s.amount), 0) - CASE WHEN p.treat_opened_as_out_of_stock = 1 THEN IFNULL(SUM(s.amount_opened), 0) ELSE 0 END < sub_p.min_stock_amount; + +DROP VIEW uihelper_stock_current_overview_including_opened; + +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, + (SELECT name FROM quantity_units WHERE quantity_units.id = p.qu_id_stock) AS qu_unit_name, + (SELECT name_plural FROM quantity_units WHERE quantity_units.id = p.qu_id_stock) AS qu_unit_name_plural, + p.name AS product_name, + (SELECT name FROM product_groups WHERE product_groups.id = p.product_group_id) AS product_group_name, + EXISTS(SELECT * FROM shopping_list WHERE shopping_list.product_id = sc.product_id) AS on_shopping_list, + (SELECT name FROM quantity_units WHERE quantity_units.id = p.qu_id_purchase) AS qu_purchase_unit_name, + (SELECT name_plural FROM quantity_units WHERE quantity_units.id = p.qu_id_purchase) AS qu_purchase_unit_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.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 +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 +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 products p + ON sc.product_id = p.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 +LEFT JOIN locations l + ON p.location_id = l.id +WHERE p.hide_on_stock_overview = 0; diff --git a/migrations/0162.php b/migrations/0162.php new file mode 100644 index 00000000..82ab3d1e --- /dev/null +++ b/migrations/0162.php @@ -0,0 +1,18 @@ +getDatabaseService()->ExecuteDbStatement('UPDATE products SET treat_opened_as_out_of_stock = 0'); + $this->getDatabaseService()->ExecuteDbStatement("INSERT INTO user_settings (user_id, key, value) SELECT id, 'product_presets_treat_opened_as_out_of_stock', '0' FROM users"); +} diff --git a/public/viewjs/productform.js b/public/viewjs/productform.js index ab5df14f..b4bacf32 100644 --- a/public/viewjs/productform.js +++ b/public/viewjs/productform.js @@ -493,6 +493,11 @@ else if (Grocy.EditMode === 'create') { $("#default_best_before_days").val(Grocy.UserSettings.product_presets_default_due_days); } + + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING) + { + $("#treat_opened_as_out_of_stock").prop("checked", BoolVal(Grocy.UserSettings.product_presets_treat_opened_as_out_of_stock)); + } } Grocy.Components.ProductPicker.GetPicker().on('change', function(e) diff --git a/public/viewjs/stocksettings.js b/public/viewjs/stocksettings.js index 204be0b0..06d0afa3 100644 --- a/public/viewjs/stocksettings.js +++ b/public/viewjs/stocksettings.js @@ -2,6 +2,10 @@ $("#product_presets_product_group_id").val(Grocy.UserSettings.product_presets_product_group_id); $("#product_presets_qu_id").val(Grocy.UserSettings.product_presets_qu_id); $("#product_presets_default_due_days").val(Grocy.UserSettings.product_presets_default_due_days); +if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING && BoolVal(Grocy.UserSettings.product_presets_treat_opened_as_out_of_stock)) +{ + $("#product_presets_treat_opened_as_out_of_stock").prop("checked", true); +} $("#stock_due_soon_days").val(Grocy.UserSettings.stock_due_soon_days); $("#stock_default_purchase_amount").val(Grocy.UserSettings.stock_default_purchase_amount); $("#stock_default_consume_amount").val(Grocy.UserSettings.stock_default_consume_amount); diff --git a/services/StockService.php b/services/StockService.php index e6460c6c..8f8b0d32 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -604,14 +604,7 @@ class StockService extends BaseService $sql = 'SELECT * FROM stock_current'; if ($includeNotInStockButMissingProducts) { - $missingProductsView = 'stock_missing_products_including_opened'; - - if (!GROCY_FEATURE_SETTING_STOCK_COUNT_OPENED_PRODUCTS_AGAINST_MINIMUM_STOCK_AMOUNT) - { - $missingProductsView = 'stock_missing_products'; - } - - $sql = 'SELECT * FROM stock_current WHERE best_before_date IS NOT NULL UNION SELECT id, 0, 0, 0, 0, null, 0, 0, 0 FROM ' . $missingProductsView . ' WHERE id NOT IN (SELECT product_id FROM stock_current)'; + $sql = 'SELECT * FROM stock_current WHERE best_before_date IS NOT NULL UNION SELECT id, 0, 0, 0, 0, null, 0, 0, 0 FROM stock_missing_products WHERE id NOT IN (SELECT product_id FROM stock_current)'; } $currentStockMapped = $this->getDatabaseService()->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_GROUP | \PDO::FETCH_OBJ); @@ -640,14 +633,7 @@ class StockService extends BaseService public function GetCurrentStockOverview() { - if (!GROCY_FEATURE_SETTING_STOCK_COUNT_OPENED_PRODUCTS_AGAINST_MINIMUM_STOCK_AMOUNT) - { - return $this->getDatabase()->uihelper_stock_current_overview(); - } - else - { - return $this->getDatabase()->uihelper_stock_current_overview_including_opened(); - } + return $this->getDatabase()->uihelper_stock_current_overview(); } public function GetDueProducts(int $days = 5, bool $excludeOverdue = false) @@ -674,13 +660,7 @@ class StockService extends BaseService public function GetMissingProducts() { - $sql = 'SELECT * FROM stock_missing_products_including_opened'; - if (!GROCY_FEATURE_SETTING_STOCK_COUNT_OPENED_PRODUCTS_AGAINST_MINIMUM_STOCK_AMOUNT) - { - $sql = 'SELECT * FROM stock_missing_products'; - } - - return $this->getDatabaseService()->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ); + return $this->getDatabaseService()->ExecuteDbQuery('SELECT * FROM stock_missing_products')->fetchAll(\PDO::FETCH_OBJ); } public function GetProductDetails(int $productId) diff --git a/views/productform.blade.php b/views/productform.blade.php index 9f48bffa..899df671 100644 --- a/views/productform.blade.php +++ b/views/productform.blade.php @@ -160,7 +160,7 @@ 'additionalCssClasses' => 'locale-number-input locale-number-quantity-amount' )) -