From ccc59dfc8bb5489263ac4103a7be2673f1bfde0c Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Fri, 1 Apr 2022 18:49:17 +0200 Subject: [PATCH] Added a new product option "Disable own stock" (closes #564) --- changelog/67_UNRELEASED_xxxx-xx-xx.md | 4 +- controllers/StockController.php | 6 +-- grocy.openapi.json | 9 ++-- localization/strings.pot | 6 +++ migrations/0180.sql | 66 +++++++++++++++++++++++ public/viewjs/components/productcard.js | 9 ++++ public/viewjs/components/productpicker.js | 16 ++++-- views/components/productcard.blade.php | 7 ++- views/productform.blade.php | 14 +++++ views/stockoverview.blade.php | 14 ++--- 10 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 migrations/0180.sql diff --git a/changelog/67_UNRELEASED_xxxx-xx-xx.md b/changelog/67_UNRELEASED_xxxx-xx-xx.md index 8298b042..26c7ceea 100644 --- a/changelog/67_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/67_UNRELEASED_xxxx-xx-xx.md @@ -28,6 +28,8 @@ - It's now possible to change a products stock QU, even after it was once added to stock - When the product was once added to stock, there needs to exist a corresponding unit conversion for the new QU +- New product option "Disable own stock" (defaults to disabled) + - When enabled, the corresponding product can't have own stock, means it will not be selectable on purchase (useful for parent products which are just used as a summary/total view of the child products) - Added the product grocycode as a (hidden by default) column to the products list (master data) - Fixed that consuming via the consume page was not possible when `FEATURE_FLAG_STOCK_LOCATION_TRACKING` was disabled @@ -91,5 +93,5 @@ - Added a new endpoint `GET /stock/locations/{locationId}/entries` to get all stock entries of a given location (similar to the already existing endpoint `GET /stock/products/{productId}/entries`) - Endpoint `/recipes/{recipeId}/consume`: Fixed that consuming partially fulfilled recipes was possible, although an error was already returned in that case (and potentially some of the in-stock ingredients were consumed in fact) - Endpoint `/stock/products/{productId}`: - - New field/property `current_price` which returns the current price of the corresponding products based on the stock entry to use next defined by the default consume rule (Opened first, then first due first, then first in first out) + - New field/property `current_price` which returns the current price of the corresponding product, based on the stock entry to use next (defined by the default consume rule "Opened first, then first due first, then first in first out") or on the last price if the product is currently not in stock - The field/property `oldest_price` is deprecated and will be removed in a future version (this had no real sense, currently returns the same as `current_price`) diff --git a/controllers/StockController.php b/controllers/StockController.php index 2dc276df..75f0f8d3 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -24,7 +24,7 @@ class StockController extends BaseController public function Inventory(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) { return $this->renderPage($response, 'inventory', [ - 'products' => $this->getDatabase()->products()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'), + 'products' => $this->getDatabase()->products()->where('active = 1 AND no_own_stock = 0')->orderBy('name', 'COLLATE NOCASE'), 'barcodes' => $this->getDatabase()->product_barcodes_comma_separated(), 'shoppinglocations' => $this->getDatabase()->shopping_locations()->orderBy('name', 'COLLATE NOCASE'), 'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'), @@ -259,7 +259,7 @@ class StockController extends BaseController public function Purchase(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) { return $this->renderPage($response, 'purchase', [ - 'products' => $this->getDatabase()->products()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'), + 'products' => $this->getDatabase()->products()->where('active = 1 AND no_own_stock = 0')->orderBy('name', 'COLLATE NOCASE'), 'barcodes' => $this->getDatabase()->product_barcodes_comma_separated(), 'shoppinglocations' => $this->getDatabase()->shopping_locations()->orderBy('name', 'COLLATE NOCASE'), 'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'), @@ -512,7 +512,7 @@ class StockController extends BaseController public function Transfer(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) { return $this->renderPage($response, 'transfer', [ - 'products' => $this->getDatabase()->products()->where('active = 1')->where('id IN (SELECT product_id from stock_current WHERE amount_aggregated > 0)')->orderBy('name', 'COLLATE NOCASE'), + 'products' => $this->getDatabase()->products()->where('active = 1')->where('no_own_stock = 0 AND id IN (SELECT product_id from stock_current WHERE amount_aggregated > 0)')->orderBy('name', 'COLLATE NOCASE'), 'barcodes' => $this->getDatabase()->product_barcodes_comma_separated(), 'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'), 'quantityUnits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'), diff --git a/grocy.openapi.json b/grocy.openapi.json index 4e3f5885..55e0c1de 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -4475,6 +4475,9 @@ "treat_opened_as_out_of_stock": { "type": "integer" }, + "no_own_stock": { + "type": "integer" + }, "userfields": { "type": "object", "description": "Key/value pairs of userfields" @@ -4756,11 +4759,11 @@ }, "avg_price": { "type": "number", - "description": "The average price af all items currently in stock of the corresponding product" + "description": "The average price af all stock entries currently in stock of the corresponding product" }, "current_price": { "type": "number", - "description": "The current price of the corresponding products, based on the stock entry to use next defined by the default consume rule (Opened first, then first due first, then first in first out)" + "description": "The current price of the corresponding product, based on the stock entry to use next (defined by the default consume rule \"Opened first, then first due first, then first in first out\") or on the last price if the product is currently not in stock" }, "oldest_price": { "type": "number", @@ -4781,7 +4784,7 @@ }, "has_childs": { "type": "boolean", - "description": "True when the product is a parent products of others" + "description": "True when the product is a parent product of others" } }, "example": { diff --git a/localization/strings.pot b/localization/strings.pot index 3dfaa163..19a45b60 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2320,3 +2320,9 @@ msgstr "" msgid "The higher this number is, the more ingredients currently in stock are due soon, overdue or already expired" msgstr "" + +msgid "Disable own stock" +msgstr "" + +msgid "When enabled, this product can't have own stock, means it will not be selectable on purchase (useful for parent products which are just used as a summary/total view of the child products)" +msgstr "" diff --git a/migrations/0180.sql b/migrations/0180.sql new file mode 100644 index 00000000..00a6255b --- /dev/null +++ b/migrations/0180.sql @@ -0,0 +1,66 @@ +ALTER TABLE products +ADD no_own_stock TINYINT NOT NULL DEFAULT 0 CHECK(no_own_stock IN (0, 1)); + +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, + p.no_own_stock AS product_no_own_stock +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/public/viewjs/components/productcard.js b/public/viewjs/components/productcard.js index 20812f4e..29dbed99 100644 --- a/public/viewjs/components/productcard.js +++ b/public/viewjs/components/productcard.js @@ -110,7 +110,16 @@ Grocy.Components.ProductCard.Refresh = function(productId) $("#productcard-product-picture").addClass("d-none"); } + $("#productcard-product-stock-amount-wrapper").removeClass("d-none"); + $("#productcard-aggregated-amounts").addClass("pl-2"); + if (productDetails.product.no_own_stock == 1) + { + $("#productcard-product-stock-amount-wrapper").addClass("d-none"); + $("#productcard-aggregated-amounts").removeClass("pl-2"); + } + RefreshContextualTimeago(".productcard"); + RefreshLocaleNumberDisplay(".productcard"); }, function(xhr) { diff --git a/public/viewjs/components/productpicker.js b/public/viewjs/components/productpicker.js index a757625b..2a9d49ec 100644 --- a/public/viewjs/components/productpicker.js +++ b/public/viewjs/components/productpicker.js @@ -122,11 +122,19 @@ if (!prefillProductId2.isEmpty()) if (typeof prefillProductId !== "undefined") { $('#product_id').val(prefillProductId); - $('#product_id').data('combobox').refresh(); - $('#product_id').trigger('change'); - var nextInputElement = $(Grocy.Components.ProductPicker.GetPicker().parent().data('next-input-selector').toString()); - nextInputElement.focus(); + if ($('#product_id').val() != null) + { + $('#product_id').data('combobox').refresh(); + $('#product_id').trigger('change'); + + var nextInputElement = $(Grocy.Components.ProductPicker.GetPicker().parent().data('next-input-selector').toString()); + nextInputElement.focus(); + } + else + { + Grocy.Components.ProductPicker.GetInputElement().focus(); + } } if (GetUriParam("flow") === "InplaceAddBarcodeToExistingProduct") diff --git a/views/components/productcard.blade.php b/views/components/productcard.blade.php index ced05336..1038ca21 100644 --- a/views/components/productcard.blade.php +++ b/views/components/productcard.blade.php @@ -38,8 +38,11 @@ href="#productcard-product-description">{{ $__t('Show more') }} - {{ $__t('Stock amount') }}: + {{ $__t('Stock amount') }}: + + + +
+
+ no_own_stock == 1) checked @endif class="form-check-input custom-control-input" type="checkbox" id="no_own_stock" name="no_own_stock" value="1"> + +
+
+