From 6c7420ea08ba589d339b380db2f30f153ec2ef12 Mon Sep 17 00:00:00 2001 From: kriddles <54413450+kriddles@users.noreply.github.com> Date: Thu, 19 Dec 2019 12:48:36 -0600 Subject: [PATCH] Stock Service Updates (#421) * viewjs consume: implement location and update stock specific * Transfer Products * services StockService#GetProductStockEntriesByLocation: add method * services StockService#AddProduct: check for stock and locations * services StockService: include location_id * services StockService#LocationExists: add method * services StockService#UndoBooking: fix based on stockRow * Reimplement StockServer->TransferProduct (one loop for the whole action to preserve stock_id) * Ensure that the location_id is never NULL in the stock and stock_log table (checked by an INSERT trigger, sets the products default location if empty) * Only consider stock amount at the given location on consume, if supplied * Restore more/old display text for "specific stock entry" * Don't allow transfering tare weight enabled products * Various small changes (code style, missing OpenAPI endpoint, remove location_id null checking) * Updated translations strings * Added transaction_id and correlation_id to stock_log entries to group them together * ProductCard - location to default location label change * Also undo correlated bookings on undo * Added API endpoints for listing and undoing transactions and use them on purchase/consume/inventory/stockoverview * Initial Stock detail page * Allow Undo for Tranfers * Price step to .01 * Some localization string changes & fixes --- controllers/StockApiController.php | 147 ++++++- controllers/StockController.php | 40 +- grocy.openapi.json | 380 ++++++++++++++++++ localization/en/stock_transaction_types.po | 12 + localization/stock_transaction_types.pot | 12 + localization/strings.pot | 45 ++- migrations/0095.sql | 37 ++ public/viewjs/consume.js | 248 +++++++++--- public/viewjs/inventory.js | 16 +- public/viewjs/purchase.js | 27 +- public/viewjs/stockdetail.js | 293 ++++++++++++++ public/viewjs/stockedit.js | 151 +++++++ public/viewjs/stockoverview.js | 28 +- public/viewjs/transfer.js | 441 +++++++++++++++++++++ routes.php | 9 + services/StockService.php | 391 +++++++++++++++++- views/components/productcard.blade.php | 2 +- views/consume.blade.php | 24 +- views/inventory.blade.php | 2 +- views/layout/default.blade.php | 8 + views/purchase.blade.php | 2 +- views/recipeposform.blade.php | 2 +- views/stockdetail.blade.php | 197 +++++++++ views/stockedit.blade.php | 81 ++++ views/stockjournal.blade.php | 4 + views/stockoverview.blade.php | 10 + views/transfer.blade.php | 84 ++++ 27 files changed, 2614 insertions(+), 79 deletions(-) create mode 100644 migrations/0095.sql create mode 100644 public/viewjs/stockdetail.js create mode 100644 public/viewjs/stockedit.js create mode 100644 public/viewjs/transfer.js create mode 100644 views/stockdetail.blade.php create mode 100644 views/stockedit.blade.php create mode 100644 views/transfer.blade.php diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php index 634793c2..c9cc5538 100644 --- a/controllers/StockApiController.php +++ b/controllers/StockApiController.php @@ -113,6 +113,108 @@ class StockApiController extends BaseApiController } } + public function EditStock(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + $requestBody = $request->getParsedBody(); + + try + { + if ($requestBody === null) + { + throw new \Exception('Request body could not be parsed (probably invalid JSON format or missing/wrong Content-Type header)'); + } + + if (!array_key_exists('stock_row_id', $requestBody)) + { + throw new \Exception('A stock row id is required'); + } + + if (!array_key_exists('amount', $requestBody)) + { + throw new \Exception('An amount is required'); + } + + $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']; + } + + $bookingId = $this->StockService->EditStock($requestBody['stock_row_id'], $requestBody['amount'], $bestBeforeDate, $locationId, $price); + return $this->ApiResponse($this->Database->stock_log($bookingId)); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + + public function TransferProduct(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + $requestBody = $request->getParsedBody(); + + try + { + if ($requestBody === null) + { + throw new \Exception('Request body could not be parsed (probably invalid JSON format or missing/wrong Content-Type header)'); + } + + if (!array_key_exists('amount', $requestBody)) + { + throw new \Exception('An amount is required'); + } + + if (!array_key_exists('location_id_from', $requestBody)) + { + throw new \Exception('A transfer from location is required'); + } + + if (!array_key_exists('location_id_to', $requestBody)) + { + throw new \Exception('A transfer to location is required'); + } + + $specificStockEntryId = 'default'; + if (array_key_exists('stock_entry_id', $requestBody) && !empty($requestBody['stock_entry_id'])) + { + $specificStockEntryId = $requestBody['stock_entry_id']; + } + + $bookingId = $this->StockService->TransferProduct($args['productId'], $requestBody['amount'], $requestBody['location_id_from'], $requestBody['location_id_to'], $specificStockEntryId); + return $this->ApiResponse($this->Database->stock_log($bookingId)); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + + public function TransferProductByBarcode(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + try + { + $args['productId'] = $this->StockService->GetProductIdFromBarcode($args['barcode']); + return $this->TransferProduct($request, $response, $args); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + public function ConsumeProduct(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { $requestBody = $request->getParsedBody(); @@ -147,13 +249,19 @@ class StockApiController extends BaseApiController $specificStockEntryId = $requestBody['stock_entry_id']; } + $locationId = null; + if (array_key_exists('location_id', $requestBody) && !empty($requestBody['location_id']) && is_numeric($requestBody['location_id'])) + { + $locationId = $requestBody['location_id']; + } + $recipeId = null; if (array_key_exists('recipe_id', $requestBody) && is_numeric($requestBody['recipe_id'])) { $recipeId = $requestBody['recipe_id']; } - $bookingId = $this->StockService->ConsumeProduct($args['productId'], $requestBody['amount'], $spoiled, $transactionType, $specificStockEntryId, $recipeId); + $bookingId = $this->StockService->ConsumeProduct($args['productId'], $requestBody['amount'], $spoiled, $transactionType, $specificStockEntryId, $recipeId, $locationId); return $this->ApiResponse($this->Database->stock_log($bookingId)); } catch (\Exception $ex) @@ -449,11 +557,29 @@ class StockApiController extends BaseApiController } } + public function UndoTransaction(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + try + { + $this->ApiResponse($this->StockService->UndoTransaction($args['transactionId'])); + return $this->EmptyApiResponse($response); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + public function ProductStockEntries(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { return $this->ApiResponse($this->StockService->GetProductStockEntries($args['productId'])); } + public function ProductStockLocations(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->ApiResponse($this->StockService->GetProductStockLocations($args['productId'])); + } + public function StockBooking(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { try @@ -472,4 +598,23 @@ class StockApiController extends BaseApiController return $this->GenericErrorResponse($response, $ex->getMessage()); } } + + public function StockTransactions(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + try + { + $transactionRows = $this->Database->stock_log()->where('transaction_id = :1', $args['transactionId'])->fetchAll(); + + if (count($transactionRows) === 0) + { + throw new \Exception('No transaction was found by the given transaction id'); + } + + return $this->ApiResponse($transactionRows); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } } diff --git a/controllers/StockController.php b/controllers/StockController.php index ec946ae0..7436d02d 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -38,6 +38,25 @@ class StockController extends BaseController ]); } + public function Detail(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + $usersService = new UsersService(); + $nextXDays = $usersService->GetUserSettings(GROCY_USER_ID)['stock_expring_soon_days']; + + return $this->AppContainer->view->render($response, 'stockdetail', [ + 'products' => $this->Database->products()->orderBy('name'), + 'quantityunits' => $this->Database->quantity_units()->orderBy('name'), + 'locations' => $this->Database->locations()->orderBy('name'), + 'currentStockDetail' => $this->Database->stock()->orderBy('product_id'), + 'currentStockLocations' => $this->StockService->GetCurrentStockLocations(), + 'missingProducts' => $this->StockService->GetMissingProducts(), + 'nextXDays' => $nextXDays, + 'productGroups' => $this->Database->product_groups()->orderBy('name'), + 'userfields' => $this->UserfieldsService->GetFields('products'), + 'userfieldValues' => $this->UserfieldsService->GetAllValues('products') + ]); + } + public function Purchase(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { return $this->AppContainer->view->render($response, 'purchase', [ @@ -50,7 +69,17 @@ class StockController extends BaseController { return $this->AppContainer->view->render($response, 'consume', [ 'products' => $this->Database->products()->orderBy('name'), - 'recipes' => $this->Database->recipes()->orderBy('name') + 'recipes' => $this->Database->recipes()->orderBy('name'), + 'locations' => $this->Database->locations()->orderBy('name') + ]); + } + + public function Transfer(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->AppContainer->view->render($response, 'transfer', [ + 'products' => $this->Database->products()->orderBy('name'), + 'recipes' => $this->Database->recipes()->orderBy('name'), + 'locations' => $this->Database->locations()->orderBy('name') ]); } @@ -62,6 +91,14 @@ class StockController extends BaseController ]); } + public function StockEdit(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->AppContainer->view->render($response, 'stockedit', [ + 'products' => $this->Database->products()->orderBy('name'), + 'locations' => $this->Database->locations()->orderBy('name') + ]); + } + public function ShoppingList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { $listId = 1; @@ -271,6 +308,7 @@ class StockController extends BaseController { return $this->AppContainer->view->render($response, 'stockjournal', [ 'stockLog' => $this->Database->stock_log()->orderBy('row_created_timestamp', 'DESC'), + 'locations' => $this->Database->locations()->orderBy('name'), 'products' => $this->Database->products()->orderBy('name'), 'quantityunits' => $this->Database->quantity_units()->orderBy('name') ]); diff --git a/grocy.openapi.json b/grocy.openapi.json index 77036fb8..4fd92a4d 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -1050,6 +1050,78 @@ } } } + }, + "put": { + "summary": "Edits the stock entry", + "tags": [ + "Stock" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stock_row_id": { + "type": "number", + "format": "number", + "description": "The Stock Row Id" + }, + "amount": { + "type": "number", + "format": "number", + "description": "The amount to add - please note that when tare weight handling for the product is enabled, this needs to be the amount including the container weight (gross), the amount to be posted will be automatically calculated based on what is in stock and the defined tare weight" + }, + "best_before_date": { + "type": "string", + "format": "date", + "description": "The best before date of the product to add, when omitted, the current date is used" + }, + "location_id": { + "type": "number", + "format": "integer", + "description": "If omitted, the default location of the product is used" + }, + "price": { + "type": "number", + "format": "number", + "description": "The price per purchase quantity unit in configured currency" + } + }, + "example": { + "stock_row_id": 2, + "amount": 1, + "best_before_date": "2019-01-19", + "location_id": 2, + "price": "1.99" + } + } + } + } + }, + "responses": { + "200": { + "description": "The operation was successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockLogEntry" + } + } + } + }, + "400": { + "description": "The operation was not successful (possible errors are: Not existing product, invalid transaction type)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } } }, "/stock/volatile": { @@ -1128,6 +1200,50 @@ } } }, + "/stock/products/{productId}/locations": { + "get": { + "summary": "Returns all locations where the given product currently has stock", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "An array of StockLocation objects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockLocation" + } + } + } + } + }, + "400": { + "description": "The operation was not successful (possible errors are: Not existing product)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } + } + }, "/stock/products/{productId}/entries": { "get": { "summary": "Returns all stock entries of the given product in order of next use (first expiring first, then first in first out)", @@ -1341,6 +1457,11 @@ "type": "number", "format": "integer", "description": "A valid recipe id for which this product was used (for statistical purposes only)" + }, + "location_id": { + "type": "number", + "format": "integer", + "description": "A valid location id (if supplied, only stock at the given location is considered, if ommitted, stock of any location is considered)" } }, "example": { @@ -1376,6 +1497,82 @@ } } }, + "/stock/products/{productId}/transfer": { + "post": { + "summary": "Transfers the given amount of the given product from one location to another (this is currently not supported for tare weight handling enabled products)", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "description": "The amount to transfer - please note that when tare weight handling for the product is enabled, this needs to be the amount including the container weight (gross), the amount to be posted will be automatically calculated based on what is in stock and the defined tare weight" + }, + "location_id_from": { + "type": "number", + "format": "integer", + "description": "A valid location id, the location from where the product should be transfered" + }, + "location_id_to": { + "type": "number", + "format": "integer", + "description": "A valid location id, the location to where the product should be transfered" + }, + "stock_entry_id": { + "type": "string", + "description": "A specific stock entry id to transfer, if used, the amount has to be 1" + } + }, + "example": { + "amount": 1, + "location_id_from": 1, + "location_id_to": 2 + } + } + } + } + }, + "responses": { + "200": { + "description": "The operation was successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockLogEntry" + } + } + } + }, + "400": { + "description": "The operation was not successful (possible errors are: Not existing product, no existing from or to location, given amount > current stock amount at the source location)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } + } + }, "/stock/products/{productId}/inventory": { "post": { "summary": "Inventories the given product (adds/removes based on the given new amount)", @@ -1678,6 +1875,11 @@ "type": "number", "format": "integer", "description": "A valid recipe id for which this product was used (for statistical purposes only)" + }, + "location_id": { + "type": "number", + "format": "integer", + "description": "A valid location id (if supplied, only stock at the given location is considered, if ommitted, stock of any location is considered)" } }, "example": { @@ -1713,6 +1915,82 @@ } } }, + "/stock/products/by-barcode/{barcode}/transfer": { + "post": { + "summary": "Transfers the given amount of the by its barcode given product from one location to another (this is currently not supported for tare weight handling enabled products)", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "barcode", + "required": true, + "description": "Barcode", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "description": "The amount to transfer - please note that when tare weight handling for the product is enabled, this needs to be the amount including the container weight (gross), the amount to be posted will be automatically calculated based on what is in stock and the defined tare weight" + }, + "location_id_from": { + "type": "number", + "format": "integer", + "description": "A valid location id, the location from where the product should be transfered" + }, + "location_id_to": { + "type": "number", + "format": "integer", + "description": "A valid location id, the location to where the product should be transfered" + }, + "stock_entry_id": { + "type": "string", + "description": "A specific stock entry id to transfer, if used, the amount has to be 1" + } + }, + "example": { + "amount": 1, + "location_id_from": 1, + "location_id_to": 2 + } + } + } + } + }, + "responses": { + "200": { + "description": "The operation was successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockLogEntry" + } + } + } + }, + "400": { + "description": "The operation was not successful (possible errors are: Not existing product, no existing from or to location, given amount > current stock amount at the source location)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } + } + }, "/stock/products/by-barcode/{barcode}/inventory": { "post": { "summary": "Inventories the by its barcode given product (adds/removes based on the given new amount)", @@ -2119,6 +2397,84 @@ } } }, + "/stock/transactions/{transactionId}": { + "get": { + "summary": "Returns all stock bookings of the given transaction id", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "transactionId", + "required": true, + "description": "A valid stock transaction id", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "An array of StockLogEntry objects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockLogEntry" + } + } + } + } + }, + "400": { + "description": "The operation was not successful (possible errors are: Not existing transaction)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } + } + }, + "/stock/transactions/{transactionId}/undo": { + "post": { + "summary": "Undoes a transaction", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "transactionId", + "required": true, + "description": "A valid stock transaction id", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "The operation was successful" + }, + "400": { + "description": "The operation was not successful (possible errors are: Not existing transaction)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } + } + }, "/stock/barcodes/external-lookup/{barcode}": { "get": { "summary": "Executes an external barcode lookoup via the configured plugin with the given barcode", @@ -2847,6 +3203,7 @@ "quantity_unit_conversions", "shopping_list", "shopping_lists", + "stock", "recipes", "recipes_pos", "recipes_nestings", @@ -3023,6 +3380,29 @@ "row_created_timestamp": "2019-05-02 20:12:25" } }, + "StockLocation": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "product_id": { + "type": "integer" + }, + "location_id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "example": { + "id": "1", + "product_id": "3", + "location_id": "1", + "name": "Fridge" + } + }, "StockEntry": { "type": "object", "properties": { diff --git a/localization/en/stock_transaction_types.po b/localization/en/stock_transaction_types.po index 7242c79e..d8b65ef1 100644 --- a/localization/en/stock_transaction_types.po +++ b/localization/en/stock_transaction_types.po @@ -15,6 +15,12 @@ msgstr "" msgid "purchase" msgstr "Purchase" +msgid "transfer_to" +msgstr "Transfer To" + +msgid "transfer_from" +msgstr "Transfer From" + msgid "consume" msgstr "Consume" @@ -23,3 +29,9 @@ msgstr "Inventory correction" msgid "product-opened" msgstr "Product opened" + +msgid "stock-edit-old" +msgstr "Stock entry edited (old values)" + +msgid "stock-edit-new" +msgstr "Stock entry edited (new values)" diff --git a/localization/stock_transaction_types.pot b/localization/stock_transaction_types.pot index 9f89fc48..48930d28 100644 --- a/localization/stock_transaction_types.pot +++ b/localization/stock_transaction_types.pot @@ -15,6 +15,12 @@ msgstr "" msgid "purchase" msgstr "" +msgid "transfer_from" +msgstr "" + +msgid "transfer_to" +msgstr "" + msgid "consume" msgstr "" @@ -23,3 +29,9 @@ msgstr "" msgid "product-opened" msgstr "" + +msgid "stock-edit-old" +msgstr "" + +msgid "stock-edit-new" +msgstr "" diff --git a/localization/strings.pot b/localization/strings.pot index ac91e926..f78fdef0 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -929,9 +929,6 @@ msgstr "" msgid "Mark as opened" msgstr "" -msgid "Expires on %1$s; Bought on %2$s" -msgstr "" - msgid "Not opened" msgstr "" @@ -1567,3 +1564,45 @@ msgstr "" msgid "This means the next execution of this chore should only be scheduled every %s years" msgstr "" + +msgid "Transfer" +msgstr "" + +msgid "From location" +msgstr "" + +msgid "To location" +msgstr "" + +msgid "There are no units available at this location" +msgstr "" + +msgid "Amount: %1$s; Expires on %2$s; Bought on %3$s" +msgstr "" + +msgid "Transfered %1$s of %2$s from %3$s to %4$s" +msgstr "" + +msgid "Show stock entries" +msgstr "" + +msgid "Stock entry" +msgstr "" + +msgid "Best before date" +msgstr "" + +msgid "Purchased date" +msgstr "" + +msgid "Consume all %s for this stock entry" +msgstr "" + +msgid "Stock edit" +msgstr "" + +msgid "The amount cannot be lower than %1$s" +msgstr "" + +msgid "Stock entry successfully updated" +msgstr "" diff --git a/migrations/0095.sql b/migrations/0095.sql new file mode 100644 index 00000000..dfd01779 --- /dev/null +++ b/migrations/0095.sql @@ -0,0 +1,37 @@ +CREATE TRIGGER set_products_default_location_if_empty_stock AFTER INSERT ON stock +BEGIN + UPDATE stock + SET location_id = (SELECT location_id FROM products where id = product_id) + WHERE id = NEW.id + AND location_id IS NULL; +END; + +CREATE TRIGGER set_products_default_location_if_empty_stock_log AFTER INSERT ON stock_log +BEGIN + UPDATE stock_log + SET location_id = (SELECT location_id FROM products where id = product_id) + WHERE id = NEW.id + AND location_id IS NULL; +END; + +ALTER TABLE stock_log +ADD correlation_id TEXT; + +ALTER TABLE stock_log +ADD transaction_id TEXT; + +ALTER TABLE stock_log +ADD stock_row_id INTEGER; + +DROP VIEW stock_current_locations; +CREATE VIEW stock_current_locations +AS +SELECT + 1 AS id, -- Dummy, LessQL needs an id column + s.product_id, + s.location_id AS location_id, + l.name AS location_name +FROM stock s +JOIN locations l + ON s.location_id = l.id +GROUP BY s.product_id, s.location_id, l.name; diff --git a/public/viewjs/consume.js b/public/viewjs/consume.js index 1a7bffb4..2b37f23c 100644 --- a/public/viewjs/consume.js +++ b/public/viewjs/consume.js @@ -1,15 +1,30 @@ -$('#save-consume-button').on('click', function(e) +$(document).ready(function() { + + if (GetUriParam("embedded") !== undefined) + { + var locationId = GetUriParam('locationId'); + + if (typeof locationId === 'undefined') + { + Grocy.Components.ProductPicker.GetPicker().trigger('change'); + Grocy.Components.ProductPicker.GetInputElement().focus(); + } else { + + $("#location_id").val(locationId); + $("#location_id").trigger('change'); + $("#use_specific_stock_entry").click(); + $("#use_specific_stock_entry").trigger('change'); + } + } +}); + +$('#save-consume-button').on('click', function(e) { e.preventDefault(); var jsonForm = $('#consume-form').serializeJSON(); Grocy.FrontendHelpers.BeginUiBusy("consume-form"); - if ($("#use_specific_stock_entry").is(":checked")) - { - jsonForm.amount = 1; - } - var apiUrl = 'stock/products/' + jsonForm.product_id + '/consume'; var jsonData = {}; @@ -21,6 +36,15 @@ jsonData.stock_entry_id = jsonForm.specific_stock_entry; } + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) + { + jsonData.location_id = $("#location_id").val(); + } + else + { + jsonData.location_id = 1; + } + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_RECIPES && Grocy.Components.RecipePicker.GetValue().toString().length > 0) { jsonData.recipe_id = Grocy.Components.RecipePicker.GetValue(); @@ -71,11 +95,11 @@ if (productDetails.product.enable_tare_weight_handling == 1) { - var successMessage = __t('Removed %1$s of %2$s from stock', Math.abs(jsonForm.amount - parseFloat(productDetails.product.tare_weight)) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '
' + __t("Undo") + ''; + var successMessage = __t('Removed %1$s of %2$s from stock', Math.abs(jsonForm.amount - parseFloat(productDetails.product.tare_weight)) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '
' + __t("Undo") + ''; } else { - var successMessage =__t('Removed %1$s of %2$s from stock', Math.abs(jsonForm.amount) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '
' + __t("Undo") + ''; + var successMessage = __t('Removed %1$s of %2$s from stock', Math.abs(jsonForm.amount) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '
' + __t("Undo") + ''; } if (GetUriParam("embedded") !== undefined) @@ -86,7 +110,6 @@ } else { - Grocy.FrontendHelpers.EndUiBusy("consume-form"); toastr.success(successMessage); @@ -102,6 +125,10 @@ { Grocy.Components.RecipePicker.Clear(); } + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) + { + $("#location_id").find("option").remove().end().append(""); + } Grocy.Components.ProductPicker.GetInputElement().focus(); Grocy.FrontendHelpers.ValidateForm('consume-form'); } @@ -128,11 +155,6 @@ $('#save-mark-as-open-button').on('click', function(e) var jsonForm = $('#consume-form').serializeJSON(); Grocy.FrontendHelpers.BeginUiBusy("consume-form"); - if ($("#use_specific_stock_entry").is(":checked")) - { - jsonForm.amount = 1; - } - var apiUrl = 'stock/products/' + jsonForm.product_id + '/open'; jsonData = { }; @@ -156,7 +178,7 @@ $('#save-mark-as-open-button').on('click', function(e) } Grocy.FrontendHelpers.EndUiBusy("consume-form"); - toastr.success(__t('Marked %1$s of %2$s as opened', jsonForm.amount + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '
' + __t("Undo") + ''); + toastr.success(__t('Marked %1$s of %2$s as opened', jsonForm.amount + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '
' + __t("Undo") + ''); $('#amount').val(Grocy.UserSettings.stock_default_consume_amount); Grocy.Components.ProductPicker.Clear(); @@ -178,6 +200,69 @@ $('#save-mark-as-open-button').on('click', function(e) ); }); +$("#location_id").on('change', function(e) +{ + var locationId = $(e.target).val(); + var sumValue = 0; + var stockId = null; + + $("#specific_stock_entry").find("option").remove().end().append(""); + if ($("#use_specific_stock_entry").is(":checked")) + { + $("#use_specific_stock_entry").click(); + } + + if (GetUriParam("embedded") !== undefined) + { + stockId = GetUriParam('stockId'); + } + + if (locationId) + { + Grocy.Api.Get("stock/products/" + Grocy.Components.ProductPicker.GetValue() + '/entries', + function(stockEntries) + { + stockEntries.forEach(stockEntry => + { + var openTxt = __t("Not opened"); + if (stockEntry.open == 1) + { + openTxt = __t("Opened"); + } + + if (stockEntry.location_id == locationId) + { + $("#specific_stock_entry").append($(""); @@ -185,6 +270,7 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e) { $("#use_specific_stock_entry").click(); } + $("#location_id").val(""); var productId = $(e.target).val(); @@ -195,9 +281,45 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e) Grocy.Api.Get('stock/products/' + productId, function(productDetails) { - $('#amount').attr('max', productDetails.stock_amount); $('#amount_qu_unit').text(productDetails.quantity_unit_stock.name); + $("#location_id").find("option").remove().end().append(""); + Grocy.Api.Get("stock/products/" + productId + '/locations', + function(stockLocations) + { + var setDefault = 0; + stockLocations.forEach(stockLocation => + { + if (productDetails.location.id == stockLocation.location_id) { + $("#location_id").append($(""); + $("#specific_stock_entry").attr("disabled", ""); + $("#specific_stock_entry").removeAttr("required"); + if ($("#use_specific_stock_entry").is(":checked")) + { + $("#use_specific_stock_entry").click(); + } + + $("#location_id_from").find("option").remove().end().append(""); + $("#amount").attr("min", "1"); + $("#amount").attr("max", "999999"); + $("#amount").attr("step", "1"); + $("#amount").parent().find(".invalid-feedback").text(__t('The amount cannot be lower than %s', '1')); + $('#amount').val(Grocy.UserSettings.stock_default_transfer_amount); + $('#amount_qu_unit').text(""); + $("#tare-weight-handling-info").addClass("d-none"); + Grocy.Components.ProductPicker.Clear(); + $("#location_id_to").val(""); + $("#location_id_from").val(""); + Grocy.Components.ProductPicker.GetInputElement().focus(); + Grocy.FrontendHelpers.ValidateForm('transfer-form'); + } + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("transfer-form"); + console.error(xhr); + } + ); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("transfer-form"); + console.error(xhr); + } + ); +}); + +Grocy.Components.ProductPicker.GetPicker().on('change', function(e) +{ + $("#specific_stock_entry").find("option").remove().end().append(""); + if ($("#use_specific_stock_entry").is(":checked")) + { + $("#use_specific_stock_entry").click(); + } + $("#location_id_to").val(""); + $("#location_id_from").val(""); + + var productId = $(e.target).val(); + + if (productId) + { + Grocy.Components.ProductCard.Refresh(productId); + + Grocy.Api.Get('stock/products/' + productId, + function(productDetails) + { + if (productDetails.product.enable_tare_weight_handling == 1) { + Grocy.Components.ProductPicker.GetPicker().parent().find(".invalid-feedback").text(__t('Products with Tare weight enabled are currently not supported for Transfer. Please select another product.')); + Grocy.Components.ProductPicker.Clear(); + return; + } + $('#amount_qu_unit').text(productDetails.quantity_unit_stock.name); + + $("#location_id_from").find("option").remove().end().append(""); + Grocy.Api.Get("stock/products/" + productId + '/locations', + function(stockLocations) + { + var setDefault = 0; + stockLocations.forEach(stockLocation => + { + if (productDetails.location.id == stockLocation.location_id) + { + $("#location_id_from").append($(""); + if ($("#use_specific_stock_entry").is(":checked")) + { + $("#use_specific_stock_entry").click(); + } + + if (locationId) + { + Grocy.Api.Get("stock/products/" + Grocy.Components.ProductPicker.GetValue() + '/entries', + function(stockEntries) + { + stockEntries.forEach(stockEntry => + { + var openTxt = __t("Not opened"); + if (stockEntry.open == 1) + { + openTxt = __t("Opened"); + } + + if (stockEntry.location_id == locationId) + { + $("#specific_stock_entry").append($("