diff --git a/changelog/52_UNRELEASED_2019-xx-xx.md b/changelog/52_UNRELEASED_2019-xx-xx.md index 185bd44e..52f5c82f 100644 --- a/changelog/52_UNRELEASED_2019-xx-xx.md +++ b/changelog/52_UNRELEASED_2019-xx-xx.md @@ -1,5 +1,11 @@ - Stock improvements - - You can now print a "Location Content Sheet" with the current stock per location - new button at the top of the stock overview page + - Products can now have variations + - Define the parent product for a product on the product edit page (only one level is possible, means a product which is used as a parent product in another product, cannot have a parent product itself) + - Parent and sub products can have stock (both are regular products, no difference from that side) + - On the stock overview page the aggregated amount is displayed next to the amount (sigma sign) + - When a recipe needs a parent product, the need is also fulfilled when enough sub product(s) are in stock + - API change (no breaking change): `/stock/products/{productId}` returns additional fields for the aggregated up amount(s): `stock_amount_aggregated` and `stock_amount_opened_aggregated` - contains the same for "normal" products, `is_aggregated_amount` indicates if aggregation has happened + - It's now possible to print a "Location Content Sheet" with the current stock per location - new button at the top of the stock overview page - The product description now can have formattings (HTML/WYSIWYG editor like for recipes) - Chores improvements - New option "Due date rollover" per chore which means the chore can never be overdue, the due date will shift forward each day when due diff --git a/controllers/StockController.php b/controllers/StockController.php index c6f0e82c..d62809cc 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -139,17 +139,23 @@ class StockController extends BaseController 'quantityunits' => $this->Database->quantity_units()->orderBy('name'), 'productgroups' => $this->Database->product_groups()->orderBy('name'), 'userfields' => $this->UserfieldsService->GetFields('products'), + 'products' => $this->Database->products()->where('parent_product_id IS NULL')->orderBy('name'), + 'isSubProductOfOthers' => false, 'mode' => 'create' ]); } else { + $product = $this->Database->products($args['productId']); + return $this->AppContainer->view->render($response, 'productform', [ - 'product' => $this->Database->products($args['productId']), + 'product' => $product, 'locations' => $this->Database->locations()->orderBy('name'), 'quantityunits' => $this->Database->quantity_units()->orderBy('name'), 'productgroups' => $this->Database->product_groups()->orderBy('name'), 'userfields' => $this->UserfieldsService->GetFields('products'), + 'products' => $this->Database->products()->where('id != :1 AND parent_product_id IS NULL', $product->id)->orderBy('name'), + 'isSubProductOfOthers' => $this->Database->products()->where('parent_product_id = :1', $product->id)->count() !== 0, 'mode' => 'edit' ]); } diff --git a/localization/demo_data.pot b/localization/demo_data.pot index 772c430a..e88e3f38 100644 --- a/localization/demo_data.pot +++ b/localization/demo_data.pot @@ -277,3 +277,9 @@ msgstr "" msgid "Polish" msgstr "" + +msgid "Milk Chocolate" +msgstr "" + +msgid "Dark Chocolate" +msgstr "" diff --git a/localization/strings.pot b/localization/strings.pot index 74edb084..c5667960 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -1316,3 +1316,9 @@ msgstr "" msgid "Are you sure to delete equipment \"%s\"?" msgstr "" + +msgid "Parent product" +msgstr "" + +msgid "Not possible because this product is already used as a parent product in another product" +msgstr "" diff --git a/migrations/0081.sql b/migrations/0081.sql new file mode 100644 index 00000000..4e5c655f --- /dev/null +++ b/migrations/0081.sql @@ -0,0 +1,158 @@ +ALTER TABLE products +ADD parent_product_id INT; + +CREATE VIEW products_resolved +AS +SELECT + p.parent_product_id parent_product_id, + p.id as sub_product_id +FROM products p +WHERE p.parent_product_id IS NOT NULL + +UNION + +SELECT + p.id parent_product_id, + p.id as sub_product_id +FROM products p +WHERE p.parent_product_id IS NULL; + +DROP VIEW stock_current; +CREATE VIEW stock_current +AS +SELECT + pr.parent_product_id AS product_id, + IFNULL((SELECT SUM(amount) FROM stock WHERE product_id = pr.parent_product_id), 0) AS amount, + SUM(s.amount) AS amount_aggregated, + MIN(s.best_before_date) AS best_before_date, + IFNULL((SELECT SUM(amount) FROM stock WHERE product_id = pr.parent_product_id AND open = 1), 0) AS amount_opened, + IFNULL((SELECT SUM(amount) FROM stock WHERE product_id IN (SELECT sub_product_id FROM products_resolved WHERE parent_product_id = pr.parent_product_id) AND open = 1), 0) AS amount_opened_aggregated, + CASE WHEN p.parent_product_id IS NOT NULL THEN 1 ELSE 0 END AS is_aggregated_amount +FROM products_resolved pr +JOIN stock s + ON pr.sub_product_id = s.product_id +JOIN products p + ON pr.sub_product_id = p.id +GROUP BY pr.parent_product_id +HAVING SUM(s.amount) > 0 + +UNION + +-- This is the same as above but sub products not rolled up (column is_aggregated_amount = 0 here) +SELECT + pr.sub_product_id AS product_id, + SUM(s.amount) AS amount, + SUM(s.amount) AS amount_aggregated, + MIN(s.best_before_date) AS best_before_date, + IFNULL((SELECT SUM(amount) FROM stock WHERE product_id = s.product_id AND open = 1), 0) AS amount_opened, + IFNULL((SELECT SUM(amount) FROM stock WHERE product_id = s.product_id AND open = 1), 0) AS amount_opened_aggregated, + 0 AS is_aggregated_amount +FROM products_resolved pr +JOIN stock s + ON pr.sub_product_id = s.product_id +WHERE pr.parent_product_id != pr.sub_product_id +GROUP BY pr.sub_product_id +HAVING SUM(s.amount) > 0; + +DROP VIEW stock_missing_products; +CREATE VIEW stock_missing_products +AS +SELECT + p.id, + MAX(p.name) AS name, + p.min_stock_amount - IFNULL(SUM(s.amount), 0) AS amount_missing, + CASE WHEN IFNULL(SUM(s.amount), 0) > 0 THEN 1 ELSE 0 END AS is_partly_in_stock +FROM products p +LEFT JOIN stock_current s + ON p.id = s.product_id +WHERE p.min_stock_amount != 0 +GROUP BY p.id +HAVING IFNULL(SUM(s.amount), 0) < p.min_stock_amount; + +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, + rp.amount * (r.desired_servings*1.0 / 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) 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 1 ELSE rp.amount * (r.desired_servings*1.0 / 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) 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 1 ELSE rp.amount * (r.desired_servings*1.0 / 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) END < 0 THEN ABS(IFNULL(sc.amount_aggregated, 0) - (CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 1 ELSE rp.amount * (r.desired_servings*1.0 / 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) END)) ELSE 0 END AS missing_amount, + IFNULL(sl.amount, 0) * p.qu_factor_purchase_to_stock AS amount_on_shopping_list, + CASE WHEN IFNULL(sc.amount_aggregated, 0) + (CASE WHEN r.not_check_shoppinglist = 1 THEN 0 ELSE IFNULL(sl.amount, 0) END * p.qu_factor_purchase_to_stock) >= CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 1 ELSE rp.amount * (r.desired_servings*1.0 / 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) END THEN 1 ELSE 0 END AS need_fulfilled_with_shopping_list, + rp.qu_id, + (CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 1 ELSE rp.amount * (r.desired_servings*1.0 / 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) END / p.qu_factor_purchase_to_stock) * pcp.last_price AS costs, + CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN 0 ELSE 1 END AS is_nested_recipe_pos, + rp.ingredient_group, + rp.id, -- Just a dummy id column + rnr.includes_recipe_id as child_recipe_id, + rp.note, + rp.variable_amount AS recipe_variable_amount, + rp.only_check_single_unit_in_stock +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 +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_price pcp + ON rp.product_id = pcp.product_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, + rp.amount * (r.desired_servings*1.0 / 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) AS recipe_amount, + IFNULL(sc.amount_aggregated, 0) AS stock_amount, + 1 AS need_fulfilled, + 0 AS missing_amount, + IFNULL(sl.amount, 0) * p.qu_factor_purchase_to_stock AS amount_on_shopping_list, + 1 AS need_fulfilled_with_shopping_list, + rp.qu_id, + (CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 1 ELSE rp.amount * (r.desired_servings*1.0 / 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) END / p.qu_factor_purchase_to_stock) * pcp.last_price AS costs, + CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN 0 ELSE 1 END AS is_nested_recipe_pos, + rp.ingredient_group, + rp.id, -- Just a dummy id column + rnr.includes_recipe_id as child_recipe_id, + rp.note, + rp.variable_amount AS recipe_variable_amount, + rp.only_check_single_unit_in_stock +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 +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_price pcp + ON rp.product_id = pcp.product_id +WHERE rp.not_check_stock_fulfillment = 1; diff --git a/public/css/grocy.css b/public/css/grocy.css index d9eb9949..dbf549f4 100644 --- a/public/css/grocy.css +++ b/public/css/grocy.css @@ -252,6 +252,12 @@ input::-webkit-inner-spin-button { box-shadow: none; } +/* Third party component customizations - Font Awesome */ +.fa-custom-sigma-sign:before { + content: "\03a3"; + font-family: sans-serif; +} + /* Third party component customizations - SB Admin 2 */ #mainNav .navbar-collapse .navbar-nav > .nav-item.dropdown > .nav-link:after, #mainNav .navbar-collapse .navbar-sidenav .nav-link-collapse:after { diff --git a/public/viewjs/components/productpicker.js b/public/viewjs/components/productpicker.js index f987e072..38617ca7 100644 --- a/public/viewjs/components/productpicker.js +++ b/public/viewjs/components/productpicker.js @@ -136,7 +136,7 @@ $('#product_id_text_input').on('blur', function(e) } var optionElement = $("#product_id option:contains(\"" + input + "\")").first(); - if (input.length > 0 && optionElement.length === 0 && typeof GetUriParam('addbarcodetoselection') === "undefined") + if (input.length > 0 && optionElement.length === 0 && typeof GetUriParam('addbarcodetoselection') === "undefined" && Grocy.Components.ProductPicker.GetPicker().parent().data('disallow-all-product-workflows').toString() === "false") { var addProductWorkflowsAdditionalCssClasses = ""; if (Grocy.Components.ProductPicker.GetPicker().parent().data('disallow-add-product-workflows').toString() === "true") diff --git a/public/viewjs/consume.js b/public/viewjs/consume.js index 6ee19d17..61b0785b 100644 --- a/public/viewjs/consume.js +++ b/public/viewjs/consume.js @@ -200,7 +200,7 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e) $("#tare-weight-handling-info").addClass("d-none"); } - if ((productDetails.stock_amount || 0) === 0) + if ((parseFloat(productDetails.stock_amount) || 0) === 0) { Grocy.Components.ProductPicker.Clear(); Grocy.FrontendHelpers.ValidateForm('consume-form'); diff --git a/public/viewjs/productform.js b/public/viewjs/productform.js index 2c0e3785..6352abdc 100644 --- a/public/viewjs/productform.js +++ b/public/viewjs/productform.js @@ -10,8 +10,16 @@ } var jsonData = $('#product-form').serializeJSON({ checkboxUncheckedValue: "0" }); + var parentProductId = jsonData.product_id; + delete jsonData.product_id; + jsonData.parent_product_id = parentProductId; Grocy.FrontendHelpers.BeginUiBusy("product-form"); + if (jsonData.parent_product_id.toString().isEmpty()) + { + jsonData.parent_product_id = null; + } + if ($("#product-picture")[0].files.length > 0) { var someRandomStuff = Math.random().toString(36).substring(2, 100) + Math.random().toString(36).substring(2, 100); diff --git a/services/DemoDataGeneratorService.php b/services/DemoDataGeneratorService.php index 282bc061..09091ddf 100644 --- a/services/DemoDataGeneratorService.php +++ b/services/DemoDataGeneratorService.php @@ -80,6 +80,8 @@ class DemoDataGeneratorService extends BaseService INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, enable_tare_weight_handling, tare_weight) VALUES ('{$this->__t_sql('Flour')}', 3, 8, 8, 1, 3, 1, 500); --21 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id) VALUES ('{$this->__t_sql('Sugar')}', 3, 3, 3, 1, 3); --22 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id) VALUES ('{$this->__t_sql('Milk')}', 2, 10, 10, 1, 6); --23 + INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, parent_product_id) VALUES ('{$this->__t_sql('Milk Chocolate')}', 4, 3, 3, 1, 1, 2); --24 + INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, parent_product_id) VALUES ('{$this->__t_sql('Dark Chocolate')}', 4, 3, 3, 1, 1, 2); --25 INSERT INTO shopping_list (note, amount) VALUES ('{$this->__t_sql('Some good snacks')}', 1); INSERT INTO shopping_list (product_id, amount) VALUES (20, 1); @@ -222,6 +224,9 @@ class DemoDataGeneratorService extends BaseService $stockService->AddProduct(22, 1, date('Y-m-d', strtotime('+200 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-20 days')), $this->RandomPrice()); $stockService->AddProduct(23, 1, date('Y-m-d', strtotime('+2 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-40 days')), $this->RandomPrice()); $stockService->AddProduct(23, 1, date('Y-m-d', strtotime('+2 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-50 days')), $this->RandomPrice()); + $stockService->AddProduct(24, 2, date('Y-m-d', strtotime('+180 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-10 days')), $this->RandomPrice()); + $stockService->AddProduct(25, 2, date('Y-m-d', strtotime('+180 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-10 days')), $this->RandomPrice()); + $stockService->AddProduct(2, 1, date('Y-m-d', strtotime('+180 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-10 days')), $this->RandomPrice()); $stockService->AddMissingProductsToShoppingList(); $stockService->OpenProduct(3, 1); $stockService->OpenProduct(6, 1); diff --git a/services/StockService.php b/services/StockService.php index 9bd792fb..66e9b087 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -11,10 +11,10 @@ class StockService extends BaseService public function GetCurrentStock($includeNotInStockButMissingProducts = false) { - $sql = 'SELECT * FROM stock_current'; + $sql = 'SELECT * FROM stock_current UNION SELECT id, 0, 0, null, 0, 0, 0 FROM stock_missing_products WHERE id NOT IN (SELECT product_id FROM stock_current)'; if ($includeNotInStockButMissingProducts) { - $sql = 'SELECT * FROM stock_current WHERE best_before_date IS NOT NULL'; + $sql = 'SELECT * FROM stock_current WHERE best_before_date IS NOT NULL UNION SELECT id, 0, 0, null, 0, 0, 0 FROM stock_missing_products WHERE id NOT IN (SELECT product_id FROM stock_current)'; } return $this->DatabaseService->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ); @@ -76,13 +76,9 @@ class StockService extends BaseService throw new \Exception('Product does not exist'); } + $stockCurrentRow = FindObjectinArrayByPropertyValue($this->GetCurrentStock(), 'product_id', $productId); + $product = $this->Database->products($productId); - $productStockAmount = $this->Database->stock()->where('product_id', $productId)->sum('amount'); - if ($productStockAmount == null) - { - $productStockAmount = 0; - } - $productStockAmountOpened = $this->Database->stock()->where('product_id = :1 AND open = 1', $productId)->sum('amount'); $productLastPurchased = $this->Database->stock_log()->where('product_id', $productId)->where('transaction_type', self::TRANSACTION_TYPE_PURCHASE)->where('undone', 0)->max('purchased_date'); $productLastUsed = $this->Database->stock_log()->where('product_id', $productId)->where('transaction_type', self::TRANSACTION_TYPE_CONSUME)->where('undone', 0)->max('used_date'); $nextBestBeforeDate = $this->Database->stock()->where('product_id', $productId)->min('best_before_date'); @@ -110,15 +106,18 @@ class StockService extends BaseService 'product' => $product, 'last_purchased' => $productLastPurchased, 'last_used' => $productLastUsed, - 'stock_amount' => $productStockAmount, - 'stock_amount_opened' => $productStockAmountOpened, + 'stock_amount' => $stockCurrentRow->amount, + 'stock_amount_opened' => $stockCurrentRow->amount_opened, + 'stock_amount_aggregated' => $stockCurrentRow->amount_aggregated, + 'stock_amount_opened_aggregated' => $stockCurrentRow->amount_opened_aggregated, 'quantity_unit_purchase' => $quPurchase, 'quantity_unit_stock' => $quStock, 'last_price' => $lastPrice, 'next_best_before_date' => $nextBestBeforeDate, 'location' => $location, 'average_shelf_life_days' => $averageShelfLifeDays, - 'spoil_rate_percent' => $spoilRate + 'spoil_rate_percent' => $spoilRate, + 'is_aggregated_amount' => $stockCurrentRow->is_aggregated_amount ); } diff --git a/views/components/productpicker.blade.php b/views/components/productpicker.blade.php index 2ec5127e..d48d3917 100644 --- a/views/components/productpicker.blade.php +++ b/views/components/productpicker.blade.php @@ -3,13 +3,17 @@ @endpush @php if(empty($disallowAddProductWorkflows)) { $disallowAddProductWorkflows = false; } @endphp +@php if(empty($disallowAllProductWorkflows)) { $disallowAllProductWorkflows = false; } @endphp @php if(empty($prefillByName)) { $prefillByName = ''; } @endphp @php if(empty($prefillById)) { $prefillById = ''; } @endphp @php if(!isset($isRequired)) { $isRequired = true; } @endphp +@php if(!isset($label)) { $label = 'Product'; } @endphp +@php if(!isset($disabled)) { $disabled = false; } @endphp +@php if(empty($hint)) { $hint = ''; } @endphp -