diff --git a/changelog/67_UNRELEASED_xxxx-xx-xx.md b/changelog/67_UNRELEASED_xxxx-xx-xx.md index cb299039..b574d6f8 100644 --- a/changelog/67_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/67_UNRELEASED_xxxx-xx-xx.md @@ -2,6 +2,17 @@ > ❗ xxxImportant upgrade informationXXX +### New feature: Notes and Userfields for stock entries + +- Stock entries can now have notes + - For example to distinguish between same, yet different products (e.g. having only a generic product "Chocolate" and note in that field what special one it is exactly this time) + - => New field on the purchase and inventory page + - => New column on the stock entries and stock journal page + - => Visible also in the "Use a specific stock item" dropdown on the consume and transfer page +- Additionally it's also possible to add arbitrary own fields by using Userfields + - => Configure the desired Userfields for the entity `stock` + - => Those Userfields are then visible on the same places as mentioned above for the built-in "Note" field + ### New feature: Recipes "Due score" - A number (new column on the recipes page) which represents a score which is higher the more ingredients, of the corresponding recipe, currently in stock are due soon, overdue or already expired diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php index 08c8ff5f..e34d69e0 100644 --- a/controllers/StockApiController.php +++ b/controllers/StockApiController.php @@ -100,42 +100,36 @@ class StockApiController extends BaseApiController } $bestBeforeDate = null; - if (array_key_exists('best_before_date', $requestBody) && IsIsoDate($requestBody['best_before_date'])) { $bestBeforeDate = $requestBody['best_before_date']; } $purchasedDate = date('Y-m-d'); - if (array_key_exists('purchased_date', $requestBody) && IsIsoDate($requestBody['purchased_date'])) { $purchasedDate = $requestBody['purchased_date']; } $price = null; - if (array_key_exists('price', $requestBody) && is_numeric($requestBody['price'])) { $price = $requestBody['price']; } $locationId = null; - if (array_key_exists('location_id', $requestBody) && is_numeric($requestBody['location_id'])) { $locationId = $requestBody['location_id']; } $shoppingLocationId = null; - if (array_key_exists('shopping_location_id', $requestBody) && is_numeric($requestBody['shopping_location_id'])) { $shoppingLocationId = $requestBody['shopping_location_id']; } $transactionType = StockService::TRANSACTION_TYPE_PURCHASE; - if (array_key_exists('transaction_type', $requestBody) && !empty($requestBody['transactiontype'])) { $transactionType = $requestBody['transactiontype']; @@ -147,7 +141,13 @@ class StockApiController extends BaseApiController $stockLabelType = intval($requestBody['stock_label_type']); } - $transactionId = $this->getStockService()->AddProduct($args['productId'], $requestBody['amount'], $bestBeforeDate, $transactionType, $purchasedDate, $price, $locationId, $shoppingLocationId, $unusedTransactionId, $stockLabelType); + $note = null; + if (array_key_exists('note', $requestBody)) + { + $note = $requestBody['note']; + } + + $transactionId = $this->getStockService()->AddProduct($args['productId'], $requestBody['amount'], $bestBeforeDate, $transactionType, $purchasedDate, $price, $locationId, $shoppingLocationId, $unusedTransactionId, $stockLabelType, false, $note); $args['transactionId'] = $transactionId; return $this->StockTransactions($request, $response, $args); @@ -394,34 +394,36 @@ class StockApiController extends BaseApiController } $bestBeforeDate = null; - if (array_key_exists('best_before_date', $requestBody) && IsIsoDate($requestBody['best_before_date'])) { $bestBeforeDate = $requestBody['best_before_date']; } $price = null; - if (array_key_exists('price', $requestBody) && is_numeric($requestBody['price'])) { $price = $requestBody['price']; } $locationId = null; - if (array_key_exists('location_id', $requestBody) && is_numeric($requestBody['location_id'])) { $locationId = $requestBody['location_id']; } $shoppingLocationId = null; - if (array_key_exists('shopping_location_id', $requestBody) && is_numeric($requestBody['shopping_location_id'])) { $shoppingLocationId = $requestBody['shopping_location_id']; } - $transactionId = $this->getStockService()->EditStockEntry($args['entryId'], $requestBody['amount'], $bestBeforeDate, $locationId, $shoppingLocationId, $price, $requestBody['open'], $requestBody['purchased_date']); + $note = null; + if (array_key_exists('note', $requestBody)) + { + $note = $requestBody['note']; + } + + $transactionId = $this->getStockService()->EditStockEntry($args['entryId'], $requestBody['amount'], $bestBeforeDate, $locationId, $shoppingLocationId, $price, $requestBody['open'], $requestBody['purchased_date'], $note); $args['transactionId'] = $transactionId; return $this->StockTransactions($request, $response, $args); } @@ -506,7 +508,13 @@ class StockApiController extends BaseApiController $stockLabelType = intval($requestBody['stock_label_type']); } - $transactionId = $this->getStockService()->InventoryProduct($args['productId'], $requestBody['new_amount'], $bestBeforeDate, $locationId, $price, $shoppingLocationId, $purchasedDate, $stockLabelType); + $note = null; + if (array_key_exists('note', $requestBody)) + { + $note = $requestBody['note']; + } + + $transactionId = $this->getStockService()->InventoryProduct($args['productId'], $requestBody['new_amount'], $bestBeforeDate, $locationId, $price, $shoppingLocationId, $purchasedDate, $stockLabelType, $note); $args['transactionId'] = $transactionId; return $this->StockTransactions($request, $response, $args); } diff --git a/controllers/StockController.php b/controllers/StockController.php index c1f9b901..2dc276df 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -29,7 +29,8 @@ class StockController extends BaseController 'shoppinglocations' => $this->getDatabase()->shopping_locations()->orderBy('name', 'COLLATE NOCASE'), 'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'), 'quantityUnits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'), - 'quantityUnitConversionsResolved' => $this->getDatabase()->quantity_unit_conversions_resolved() + 'quantityUnitConversionsResolved' => $this->getDatabase()->quantity_unit_conversions_resolved(), + 'userfields' => $this->getUserfieldsService()->GetFields('stock') ]); } @@ -59,7 +60,9 @@ class StockController extends BaseController 'products' => $this->getDatabase()->products()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'), 'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'), 'users' => $usersService->GetUsersAsDto(), - 'transactionTypes' => GetClassConstants('\Grocy\Services\StockService', 'TRANSACTION_TYPE_') + 'transactionTypes' => GetClassConstants('\Grocy\Services\StockService', 'TRANSACTION_TYPE_'), + 'userfieldsStock' => $this->getUserfieldsService()->GetFields('stock'), + 'userfieldValuesStock' => $this->getUserfieldsService()->GetAllValues('stock') ]); } @@ -261,7 +264,8 @@ class StockController extends BaseController 'shoppinglocations' => $this->getDatabase()->shopping_locations()->orderBy('name', 'COLLATE NOCASE'), 'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'), 'quantityUnits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'), - 'quantityUnitConversionsResolved' => $this->getDatabase()->quantity_unit_conversions_resolved() + 'quantityUnitConversionsResolved' => $this->getDatabase()->quantity_unit_conversions_resolved(), + 'userfields' => $this->getUserfieldsService()->GetFields('stock') ]); } @@ -498,8 +502,10 @@ class StockController extends BaseController 'stockEntries' => $this->getDatabase()->stock()->orderBy('product_id'), 'currentStockLocations' => $this->getStockService()->GetCurrentStockLocations(), 'nextXDays' => $nextXDays, - 'userfields' => $this->getUserfieldsService()->GetFields('products'), - 'userfieldValues' => $this->getUserfieldsService()->GetAllValues('products') + 'userfieldsProducts' => $this->getUserfieldsService()->GetFields('products'), + 'userfieldValuesProducts' => $this->getUserfieldsService()->GetAllValues('products'), + 'userfieldsStock' => $this->getUserfieldsService()->GetFields('stock'), + 'userfieldValuesStock' => $this->getUserfieldsService()->GetAllValues('stock') ]); } diff --git a/grocy.openapi.json b/grocy.openapi.json index 5bb709b5..26ce7f85 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -1911,6 +1911,10 @@ "stock_label_type": { "type": "integer", "description": "`1` = No label, `2` = Single label, `3` = Label per unit" + }, + "note": { + "type": "string", + "description": "An optional note for the corresponding stock entry" } }, "example": { @@ -2167,6 +2171,10 @@ "stock_label_type": { "type": "integer", "description": "`1` = No label, `2` = Single label, `3` = Label per unit (only applies to added products)" + }, + "note": { + "type": "string", + "description": "An optional note for the corresponding stock entry (only applies to added products)" } } } @@ -4659,6 +4667,9 @@ "type": "string", "format": "date" }, + "note": { + "type": "string" + }, "row_created_timestamp": { "type": "string", "format": "date-time" @@ -5286,6 +5297,9 @@ "transaction_type": { "$ref": "#/components/schemas/StockTransactionType" }, + "note": { + "type": "string" + }, "row_created_timestamp": { "type": "string", "format": "date-time" diff --git a/migrations/0178.sql b/migrations/0178.sql new file mode 100644 index 00000000..7a0b94c8 --- /dev/null +++ b/migrations/0178.sql @@ -0,0 +1,100 @@ +ALTER TABLE stock +ADD note TEXT; + +ALTER TABLE stock_log +ADD note TEXT; + +PRAGMA legacy_alter_table = ON; + +ALTER TABLE userfield_values RENAME TO userfield_values_old; + +CREATE TABLE userfield_values ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + field_id INTEGER NOT NULL, + object_id TEXT NOT NULL, + value TEXT NOT NULL, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')), + + UNIQUE(field_id, object_id) +); + +INSERT INTO userfield_values + (id, field_id, object_id, value, row_created_timestamp) +SELECT id, field_id, object_id, value, row_created_timestamp +FROM userfield_values_old; + +DROP TABLE userfield_values_old; + +CREATE TRIGGER userfield_values_special_handling AFTER INSERT ON userfield_values +BEGIN + -- Entity stock: + -- object_id is the transaction_id on insert -> replace it by the corresponding stock_id + INSERT OR REPLACE INTO userfield_values + (field_id, object_id, value) + SELECT uv.field_id, sl.stock_id, uv.value + FROM userfield_values uv + JOIN stock_log sl + ON uv.object_id = sl.transaction_id + AND sl.transaction_type IN ('purchase', 'inventory-correction') + WHERE uv.field_id IN (SELECT id FROM userfields WHERE entity = 'stock') + AND uv.field_id = NEW.field_id + AND uv.object_id = NEW.object_id; + + DELETE FROM userfield_values + WHERE field_id IN (SELECT id FROM userfields WHERE entity = 'stock') + AND field_id = NEW.field_id + AND object_id = NEW.object_id; +END; + +DROP VIEW stock_splits; +CREATE VIEW stock_splits +AS + +/* + Helper view which shows splitted stock rows which could be compacted + (a stock_id starting with "x" indicates that this entry shouldn't be compacted) +*/ + +SELECT + product_id, + SUM(amount) AS total_amount, + MIN(stock_id) AS stock_id_to_keep, + MAX(id) AS id_to_keep, + GROUP_CONCAT(id) AS id_group, + GROUP_CONCAT(stock_id) AS stock_id_group, + id -- Dummy +FROM stock +WHERE stock_id NOT LIKE 'x%' +GROUP BY product_id, best_before_date, purchased_date, price, open, opened_date, location_id, shopping_location_id, IFNULL(note, '') +HAVING COUNT(*) > 1; + +DROP VIEW uihelper_stock_journal; +CREATE VIEW uihelper_stock_journal +AS +SELECT + sl.id, + sl.row_created_timestamp, + sl.correlation_id, + sl.undone, + sl.undone_timestamp, + sl.transaction_type, + sl.spoiled, + sl.amount, + sl.location_id, + l.name AS location_name, + p.name AS product_name, + qu.name AS qu_name, + qu.name_plural AS qu_name_plural, + u.display_name AS user_display_name, + p.id AS product_id, + sl.note, + sl.stock_id +FROM stock_log sl +LEFT JOIN users_dto u + ON sl.user_id = u.id +JOIN products p + ON sl.product_id = p.id +JOIN locations l + ON sl.location_id = l.id +JOIN quantity_units qu + ON p.qu_id_stock = qu.id; diff --git a/public/viewjs/components/userfieldsform.js b/public/viewjs/components/userfieldsform.js index 968dd638..3c8b19fd 100644 --- a/public/viewjs/components/userfieldsform.js +++ b/public/viewjs/components/userfieldsform.js @@ -171,6 +171,80 @@ Grocy.Components.UserfieldsForm.Load = function() ); } +Grocy.Components.UserfieldsForm.Clear = function() +{ + if (!$("#userfields-form").length) + { + return; + } + + Grocy.Api.Get('objects/userfields?query[]=entity=' + $("#userfields-form").data("entity"), + function(result) + { + $.each(result, function(key, userfield) + { + var input = $(".userfield-input[data-userfield-name='" + userfield.name + "']"); + + if (input.attr("type") == "checkbox") + { + input.prop("checked", false); + } + else if (input.hasAttr("multiple")) + { + input.val(""); + $(".selectpicker").selectpicker("render"); + } + else if (input.attr('type') == "file") + { + var formGroup = input.parent().parent().parent(); + + formGroup.find("label.custom-file-label").text(""); + formGroup.find(".userfield-file-show").attr('href', U('/files/userfiles/' + value)); + formGroup.find('.userfield-file-show').removeClass('d-none'); + formGroup.find('img.userfield-current-file') + .attr('src', U('/files/userfiles/' + value + '?force_serve_as=picture&best_fit_width=250&best_fit_height=250')); + LoadImagesLazy(); + + formGroup.find('.userfield-file-delete').click( + function() + { + formGroup.find("label.custom-file-label").text(__t("No file selected")); + formGroup.find(".userfield-file-show").addClass('d-none'); + input.attr('data-old-file', ""); + } + ); + + input.on("change", function(e) + { + formGroup.find(".userfield-file-show").addClass('d-none'); + }); + } + else if (input.attr("data-userfield-type") == "link") + { + var formRow = input.parent().parent(); + formRow.find(".userfield-link-title").val(data.title); + formRow.find(".userfield-link-link").val(data.link); + + input.val(""); + } + else + { + input.val(""); + } + }); + + $("form").each(function() + { + Grocy.FrontendHelpers.ValidateForm(this.id); + }); + }, + function(xhr) + { + console.error(xhr); + } + ); +} + $(".userfield-link").keyup(function(e) { var formRow = $(this).parent().parent(); diff --git a/public/viewjs/consume.js b/public/viewjs/consume.js index 6f14174e..1893597b 100644 --- a/public/viewjs/consume.js +++ b/public/viewjs/consume.js @@ -286,10 +286,16 @@ function OnLocationChange(locationId, stockId) { if ($("#specific_stock_entry option[value='" + stockEntry.stock_id + "']").length == 0) { + var noteTxt = ""; + if (stockEntry.note != null && !stockEntry.note.isEmpty()) + { + noteTxt = " " + stockEntry.note; + } + $("#specific_stock_entry").append($("