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
This commit is contained in:
kriddles
2019-12-19 12:48:36 -06:00
committed by Bernd Bestel
parent 0be1994c02
commit 6c7420ea08
27 changed files with 2614 additions and 79 deletions

View File

@@ -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) public function ConsumeProduct(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{ {
$requestBody = $request->getParsedBody(); $requestBody = $request->getParsedBody();
@@ -147,13 +249,19 @@ class StockApiController extends BaseApiController
$specificStockEntryId = $requestBody['stock_entry_id']; $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; $recipeId = null;
if (array_key_exists('recipe_id', $requestBody) && is_numeric($requestBody['recipe_id'])) if (array_key_exists('recipe_id', $requestBody) && is_numeric($requestBody['recipe_id']))
{ {
$recipeId = $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)); return $this->ApiResponse($this->Database->stock_log($bookingId));
} }
catch (\Exception $ex) 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) public function ProductStockEntries(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{ {
return $this->ApiResponse($this->StockService->GetProductStockEntries($args['productId'])); 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) public function StockBooking(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{ {
try try
@@ -472,4 +598,23 @@ class StockApiController extends BaseApiController
return $this->GenericErrorResponse($response, $ex->getMessage()); 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());
}
}
} }

View File

@@ -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) public function Purchase(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{ {
return $this->AppContainer->view->render($response, 'purchase', [ return $this->AppContainer->view->render($response, 'purchase', [
@@ -50,7 +69,17 @@ class StockController extends BaseController
{ {
return $this->AppContainer->view->render($response, 'consume', [ return $this->AppContainer->view->render($response, 'consume', [
'products' => $this->Database->products()->orderBy('name'), '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) public function ShoppingList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{ {
$listId = 1; $listId = 1;
@@ -271,6 +308,7 @@ class StockController extends BaseController
{ {
return $this->AppContainer->view->render($response, 'stockjournal', [ return $this->AppContainer->view->render($response, 'stockjournal', [
'stockLog' => $this->Database->stock_log()->orderBy('row_created_timestamp', 'DESC'), 'stockLog' => $this->Database->stock_log()->orderBy('row_created_timestamp', 'DESC'),
'locations' => $this->Database->locations()->orderBy('name'),
'products' => $this->Database->products()->orderBy('name'), 'products' => $this->Database->products()->orderBy('name'),
'quantityunits' => $this->Database->quantity_units()->orderBy('name') 'quantityunits' => $this->Database->quantity_units()->orderBy('name')
]); ]);

View File

@@ -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": { "/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": { "/stock/products/{productId}/entries": {
"get": { "get": {
"summary": "Returns all stock entries of the given product in order of next use (first expiring first, then first in first out)", "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", "type": "number",
"format": "integer", "format": "integer",
"description": "A valid recipe id for which this product was used (for statistical purposes only)" "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": { "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": { "/stock/products/{productId}/inventory": {
"post": { "post": {
"summary": "Inventories the given product (adds/removes based on the given new amount)", "summary": "Inventories the given product (adds/removes based on the given new amount)",
@@ -1678,6 +1875,11 @@
"type": "number", "type": "number",
"format": "integer", "format": "integer",
"description": "A valid recipe id for which this product was used (for statistical purposes only)" "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": { "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": { "/stock/products/by-barcode/{barcode}/inventory": {
"post": { "post": {
"summary": "Inventories the by its barcode given product (adds/removes based on the given new amount)", "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}": { "/stock/barcodes/external-lookup/{barcode}": {
"get": { "get": {
"summary": "Executes an external barcode lookoup via the configured plugin with the given barcode", "summary": "Executes an external barcode lookoup via the configured plugin with the given barcode",
@@ -2847,6 +3203,7 @@
"quantity_unit_conversions", "quantity_unit_conversions",
"shopping_list", "shopping_list",
"shopping_lists", "shopping_lists",
"stock",
"recipes", "recipes",
"recipes_pos", "recipes_pos",
"recipes_nestings", "recipes_nestings",
@@ -3023,6 +3380,29 @@
"row_created_timestamp": "2019-05-02 20:12:25" "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": { "StockEntry": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -15,6 +15,12 @@ msgstr ""
msgid "purchase" msgid "purchase"
msgstr "Purchase" msgstr "Purchase"
msgid "transfer_to"
msgstr "Transfer To"
msgid "transfer_from"
msgstr "Transfer From"
msgid "consume" msgid "consume"
msgstr "Consume" msgstr "Consume"
@@ -23,3 +29,9 @@ msgstr "Inventory correction"
msgid "product-opened" msgid "product-opened"
msgstr "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)"

View File

@@ -15,6 +15,12 @@ msgstr ""
msgid "purchase" msgid "purchase"
msgstr "" msgstr ""
msgid "transfer_from"
msgstr ""
msgid "transfer_to"
msgstr ""
msgid "consume" msgid "consume"
msgstr "" msgstr ""
@@ -23,3 +29,9 @@ msgstr ""
msgid "product-opened" msgid "product-opened"
msgstr "" msgstr ""
msgid "stock-edit-old"
msgstr ""
msgid "stock-edit-new"
msgstr ""

View File

@@ -929,9 +929,6 @@ msgstr ""
msgid "Mark as opened" msgid "Mark as opened"
msgstr "" msgstr ""
msgid "Expires on %1$s; Bought on %2$s"
msgstr ""
msgid "Not opened" msgid "Not opened"
msgstr "" msgstr ""
@@ -1567,3 +1564,45 @@ msgstr ""
msgid "This means the next execution of this chore should only be scheduled every %s years" msgid "This means the next execution of this chore should only be scheduled every %s years"
msgstr "" 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 ""

37
migrations/0095.sql Normal file
View File

@@ -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;

View File

@@ -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(); e.preventDefault();
var jsonForm = $('#consume-form').serializeJSON(); var jsonForm = $('#consume-form').serializeJSON();
Grocy.FrontendHelpers.BeginUiBusy("consume-form"); Grocy.FrontendHelpers.BeginUiBusy("consume-form");
if ($("#use_specific_stock_entry").is(":checked"))
{
jsonForm.amount = 1;
}
var apiUrl = 'stock/products/' + jsonForm.product_id + '/consume'; var apiUrl = 'stock/products/' + jsonForm.product_id + '/consume';
var jsonData = {}; var jsonData = {};
@@ -21,6 +36,15 @@
jsonData.stock_entry_id = jsonForm.specific_stock_entry; 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) if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_RECIPES && Grocy.Components.RecipePicker.GetValue().toString().length > 0)
{ {
jsonData.recipe_id = Grocy.Components.RecipePicker.GetValue(); jsonData.recipe_id = Grocy.Components.RecipePicker.GetValue();
@@ -71,11 +95,11 @@
if (productDetails.product.enable_tare_weight_handling == 1) 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) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + bookingResponse.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>'; 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) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + bookingResponse.transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
} }
else 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) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + bookingResponse.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>'; 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) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + bookingResponse.transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
} }
if (GetUriParam("embedded") !== undefined) if (GetUriParam("embedded") !== undefined)
@@ -86,7 +110,6 @@
} }
else else
{ {
Grocy.FrontendHelpers.EndUiBusy("consume-form"); Grocy.FrontendHelpers.EndUiBusy("consume-form");
toastr.success(successMessage); toastr.success(successMessage);
@@ -102,6 +125,10 @@
{ {
Grocy.Components.RecipePicker.Clear(); Grocy.Components.RecipePicker.Clear();
} }
if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
{
$("#location_id").find("option").remove().end().append("<option></option>");
}
Grocy.Components.ProductPicker.GetInputElement().focus(); Grocy.Components.ProductPicker.GetInputElement().focus();
Grocy.FrontendHelpers.ValidateForm('consume-form'); Grocy.FrontendHelpers.ValidateForm('consume-form');
} }
@@ -128,11 +155,6 @@ $('#save-mark-as-open-button').on('click', function(e)
var jsonForm = $('#consume-form').serializeJSON(); var jsonForm = $('#consume-form').serializeJSON();
Grocy.FrontendHelpers.BeginUiBusy("consume-form"); Grocy.FrontendHelpers.BeginUiBusy("consume-form");
if ($("#use_specific_stock_entry").is(":checked"))
{
jsonForm.amount = 1;
}
var apiUrl = 'stock/products/' + jsonForm.product_id + '/open'; var apiUrl = 'stock/products/' + jsonForm.product_id + '/open';
jsonData = { }; jsonData = { };
@@ -156,7 +178,7 @@ $('#save-mark-as-open-button').on('click', function(e)
} }
Grocy.FrontendHelpers.EndUiBusy("consume-form"); 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) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + result.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>'); 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) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + result.transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>');
$('#amount').val(Grocy.UserSettings.stock_default_consume_amount); $('#amount').val(Grocy.UserSettings.stock_default_consume_amount);
Grocy.Components.ProductPicker.Clear(); 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("<option></option>");
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($("<option>", {
value: stockEntry.stock_id,
amount: stockEntry.amount,
text: __t("Amount: %1$s; Expires on %2$s; Bought on %3$s", stockEntry.amount, moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD")) + "; " + openTxt
}));
sumValue = sumValue + parseFloat(stockEntry.amount);
if (stockEntry.stock_id == stockId)
{
$("#specific_stock_entry").val(stockId);
}
}
});
$("#amount").attr("max", sumValue);
if (sumValue == 0)
{
$("#amount").parent().find(".invalid-feedback").text(__t('There are no units available at this location'));
}
else
{
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", sumValue));
}
},
function(xhr)
{
console.error(xhr);
}
);
}
});
Grocy.Components.ProductPicker.GetPicker().on('change', function(e) Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
{ {
$("#specific_stock_entry").find("option").remove().end().append("<option></option>"); $("#specific_stock_entry").find("option").remove().end().append("<option></option>");
@@ -185,6 +270,7 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
{ {
$("#use_specific_stock_entry").click(); $("#use_specific_stock_entry").click();
} }
$("#location_id").val("");
var productId = $(e.target).val(); var productId = $(e.target).val();
@@ -195,9 +281,45 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
Grocy.Api.Get('stock/products/' + productId, Grocy.Api.Get('stock/products/' + productId,
function(productDetails) function(productDetails)
{ {
$('#amount').attr('max', productDetails.stock_amount);
$('#amount_qu_unit').text(productDetails.quantity_unit_stock.name); $('#amount_qu_unit').text(productDetails.quantity_unit_stock.name);
$("#location_id").find("option").remove().end().append("<option></option>");
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($("<option>", {
value: stockLocation.location_id,
text: stockLocation.location_name + " (" + __t("Default location") + ")"
}));
$("#location_id").val(productDetails.location.id);
$("#location_id").trigger('change');
setDefault = 1;
}
else
{
$("#location_id").append($("<option>", {
value: stockLocation.location_id,
text: stockLocation.location_name
}));
}
if (setDefault == 0)
{
$("#location_id").val(stockLocation.location_id);
$("#location_id").trigger('change');
}
});
},
function(xhr)
{
console.error(xhr);
}
);
if (productDetails.product.allow_partial_units_in_stock == 1) if (productDetails.product.allow_partial_units_in_stock == 1)
{ {
$("#amount").attr("min", "0.01"); $("#amount").attr("min", "0.01");
@@ -251,44 +373,10 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
console.error(xhr); console.error(xhr);
} }
); );
Grocy.Api.Get("stock/products/" + productId + '/entries',
function(stockEntries)
{
stockEntries.forEach(stockEntry =>
{
var openTxt = __t("Not opened");
if (stockEntry.open == 1)
{
openTxt = __t("Opened");
}
for (i = 0; i < stockEntry.amount; i++)
{
// Do this only for the first 50 entries to prevent a very long loop (is more anytime needed)?
if (i > 50)
{
break;
}
$("#specific_stock_entry").append($("<option>", {
value: stockEntry.stock_id,
text: __t("Expires on %1$s; Bought on %2$s", moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD")) + "; " + openTxt
}));
}
});
},
function(xhr)
{
console.error(xhr);
}
);
} }
}); });
$('#amount').val(Grocy.UserSettings.stock_default_consume_amount); $('#amount').val(Grocy.UserSettings.stock_default_consume_amount);
Grocy.Components.ProductPicker.GetPicker().trigger('change');
Grocy.Components.ProductPicker.GetInputElement().focus();
Grocy.FrontendHelpers.ValidateForm('consume-form'); Grocy.FrontendHelpers.ValidateForm('consume-form');
$('#amount').on('focus', function(e) $('#amount').on('focus', function(e)
@@ -323,23 +411,59 @@ $('#consume-form input').keydown(function(event)
} }
}); });
$("#specific_stock_entry").on("change", function(e)
{
if ($(e.target).val() == "")
{
var sumValue = 0;
Grocy.Api.Get("stock/products/" + Grocy.Components.ProductPicker.GetValue() + '/entries',
function(stockEntries)
{
stockEntries.forEach(stockEntry =>
{
if (stockEntry.location_id == $("#location_id").val() || stockEntry.location_id == "")
{
sumValue = sumValue + parseFloat(stockEntry.amount);
}
});
$("#amount").attr("max", sumValue);
if (sumValue == 0)
{
$("#amount").parent().find(".invalid-feedback").text(__t('There are no units available at this location'));
}
else
{
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", sumValue));
}
},
function(xhr)
{
console.error(xhr);
}
);
}
else
{
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", $('option:selected', this).attr('amount')));
$("#amount").attr("max", $('option:selected', this).attr('amount'));
}
});
$("#use_specific_stock_entry").on("change", function() $("#use_specific_stock_entry").on("change", function()
{ {
var value = $(this).is(":checked"); var value = $(this).is(":checked");
if (value) if (value)
{ {
$("#specific_stock_entry").removeAttr("disabled"); $("#specific_stock_entry").removeAttr("disabled");
$("#amount").attr("disabled", "");
$("#amount").val(1);
$("#amount").removeAttr("required");
$("#specific_stock_entry").attr("required", ""); $("#specific_stock_entry").attr("required", "");
} }
else else
{ {
$("#specific_stock_entry").attr("disabled", ""); $("#specific_stock_entry").attr("disabled", "");
$("#amount").removeAttr("disabled");
$("#amount").attr("required", "");
$("#specific_stock_entry").removeAttr("required"); $("#specific_stock_entry").removeAttr("required");
$("#specific_stock_entry").val("");
$("#location_id").trigger('change');
} }
Grocy.FrontendHelpers.ValidateForm("consume-form"); Grocy.FrontendHelpers.ValidateForm("consume-form");
@@ -358,3 +482,17 @@ function UndoStockBooking(bookingId)
} }
); );
}; };
function UndoStockTransaction(transactionId)
{
Grocy.Api.Post('stock/transactions/' + transactionId.toString() + '/undo', { },
function (result)
{
toastr.success(__t("Transaction successfully undone"));
},
function (xhr)
{
console.error(xhr);
}
);
};

View File

@@ -64,7 +64,7 @@
Grocy.Api.Get('stock/products/' + jsonForm.product_id, Grocy.Api.Get('stock/products/' + jsonForm.product_id,
function(result) function(result)
{ {
var successMessage = __t('Stock amount of %1$s is now %2$s', result.product.name, result.stock_amount + " " + __n(result.stock_amount, result.quantity_unit_stock.name, result.quantity_unit_stock.name_plural)) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + bookingResponse.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>'; var successMessage = __t('Stock amount of %1$s is now %2$s', result.product.name, result.stock_amount + " " + __n(result.stock_amount, result.quantity_unit_stock.name, result.quantity_unit_stock.name_plural)) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + bookingResponse.transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
if (GetUriParam("embedded") !== undefined) if (GetUriParam("embedded") !== undefined)
{ {
@@ -299,3 +299,17 @@ function UndoStockBooking(bookingId)
} }
); );
}; };
function UndoStockTransaction(transactionId)
{
Grocy.Api.Post('stock/transactions/' + transactionId.toString() + '/undo', { },
function (result)
{
toastr.success(__t("Transaction successfully undone"));
},
function (xhr)
{
console.error(xhr);
}
);
};

View File

@@ -70,7 +70,7 @@
); );
} }
var successMessage = __t('Added %1$s of %2$s to stock', result.amount + " " +__n(result.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + result.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>'; var successMessage = __t('Added %1$s of %2$s to stock', result.amount + " " + __n(result.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + result.transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
if (GetUriParam("embedded") !== undefined) if (GetUriParam("embedded") !== undefined)
{ {
@@ -309,3 +309,28 @@ function UndoStockBooking(bookingId)
} }
); );
}; };
function UndoStockTransaction(transactionId)
{
Grocy.Api.Post('stock/transactions/' + transactionId.toString() + '/undo', { },
function(result)
{
toastr.success(__t("Transaction successfully undone"));
Grocy.Api.Get('stock/transactions/' + transactionId.toString(),
function(result)
{
window.postMessage(WindowMessageBag("ProductChanged", result[0].product_id), Grocy.BaseUrl);
},
function (xhr)
{
console.error(xhr);
}
);
},
function(xhr)
{
console.error(xhr);
}
);
};

View File

@@ -0,0 +1,293 @@
var stockDetailTable = $('#stock-detail-table').DataTable({
'order': [[2, 'asc']],
'columnDefs': [
{ 'orderable': false, 'targets': 0 },
],
});
$('#stock-detail-table tbody').removeClass("d-none");
function bootBoxModal(message) {
bootbox.dialog({
message: message,
size: 'large',
backdrop: true,
closeButton: false,
buttons: {
cancel: {
label: __t('Cancel'),
className: 'btn-secondary responsive-button',
callback: function()
{
bootbox.hideAll();
}
}
}
});
}
$.fn.dataTable.ext.search.push(
function( settings, data, dataIndex ) {
var productId = Grocy.Components.ProductPicker.GetValue();
if ( ( isNaN( productId ) ||
productId == "" ||
//assume productId is in the first column
productId == data[1] ) )
{
return true;
}
return false;
}
);
$(document).ready(function() {
Grocy.Components.ProductPicker.GetPicker().trigger('change');
} );
Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
{
stockDetailTable.draw();
});
$(document).on('click', '.stock-consume-button', function(e)
{
e.preventDefault();
// Remove the focus from the current button
// to prevent that the tooltip stays until clicked anywhere else
document.activeElement.blur();
Grocy.FrontendHelpers.BeginUiBusy();
var productId = $(e.currentTarget).attr('data-product-id');
var locationId = $(e.currentTarget).attr('data-location-id');
var specificStockEntryId = $(e.currentTarget).attr('data-stock-id');
var stockRowId = $(e.currentTarget).attr('data-stockrow-id');
var consumeAmount = $(e.currentTarget).attr('data-consume-amount');
var wasSpoiled = $(e.currentTarget).hasClass("product-consume-button-spoiled");
Grocy.Api.Post('stock/products/' + productId + '/consume', { 'amount': consumeAmount, 'spoiled': wasSpoiled, 'location_id': locationId, 'stock_entry_id': specificStockEntryId},
function(bookingResponse)
{
Grocy.Api.Get('stock/products/' + productId,
function(result)
{
var toastMessage = __t('Removed %1$s of %2$s from stock', consumeAmount.toString() + " " + __n(consumeAmount, result.quantity_unit_stock.name, result.quantity_unit_stock.name_plural), result.product.name) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + bookingResponse.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
if (wasSpoiled)
{
toastMessage += " (" + __t("Spoiled") + ")";
}
Grocy.FrontendHelpers.EndUiBusy();
toastr.success(toastMessage);
RefreshStockDetailRow(stockRowId);
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy();
console.error(xhr);
}
);
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy();
console.error(xhr);
}
);
});
$(document).on('click', '.product-open-button', function(e)
{
e.preventDefault();
// Remove the focus from the current button
// to prevent that the tooltip stays until clicked anywhere else
document.activeElement.blur();
Grocy.FrontendHelpers.BeginUiBusy();
var productId = $(e.currentTarget).attr('data-product-id');
var productName = $(e.currentTarget).attr('data-product-name');
var productQuName = $(e.currentTarget).attr('data-product-qu-name');
var button = $(e.currentTarget);
Grocy.Api.Post('stock/products/' + productId + '/open', { 'amount': 1 },
function(bookingResponse)
{
Grocy.Api.Get('stock/products/' + productId,
function(result)
{
if (result.stock_amount == result.stock_amount_opened)
{
button.addClass("disabled");
}
Grocy.FrontendHelpers.EndUiBusy();
toastr.success(__t('Marked %1$s of %2$s as opened', 1 + " " + productQuName, productName) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + bookingResponse.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>');
RefreshProductRow(productId);
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy();
console.error(xhr);
}
);
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy();
console.error(xhr);
}
);
});
$(document).on("click", ".stock-name-cell", function(e)
{
Grocy.Components.ProductCard.Refresh($(e.currentTarget).attr("data-stock-id"));
$("#stockdetail-productcard-modal").modal("show");
});
$(document).on("click", ".product-purchase-button", function(e)
{
e.preventDefault();
var productId = $(e.currentTarget).attr("data-product-id");
bootBoxModal('<iframe height="650px" class="embed-responsive" src="' + U("/purchase?embedded&product=") + productId.toString() + '"></iframe>');
});
$(document).on("click", ".product-transfer-button", function(e)
{
e.preventDefault();
var productId = $(e.currentTarget).attr("data-product-id");
var locationId = $(e.currentTarget).attr('data-location-id');
var specificStockEntryId = $(e.currentTarget).attr('data-stock-id');
bootBoxModal('<iframe height="650px" class="embed-responsive" src="' + U("/transfer?embedded&product=") + productId.toString() + '&locationId=' + locationId.toString() + '&stockId=' + specificStockEntryId.toString() + '"></iframe>');
});
$(document).on("click", ".product-consume-custom-amount-button", function(e)
{
e.preventDefault();
var productId = $(e.currentTarget).attr("data-product-id");
var locationId = $(e.currentTarget).attr('data-location-id');
var specificStockEntryId = $(e.currentTarget).attr('data-stock-id');
bootBoxModal('<iframe height="650px" class="embed-responsive" src="' + U("/consume?embedded&product=") + productId.toString() + '&locationId=' + locationId.toString() + '&stockId=' + specificStockEntryId.toString() + '"></iframe>');
});
$(document).on("click", ".product-inventory-button", function(e)
{
e.preventDefault();
var productId = $(e.currentTarget).attr("data-product-id");
bootBoxModal('<iframe height="650px" class="embed-responsive" src="' + U("/inventory?embedded&product=") + productId.toString() + '"></iframe>');
});
$(document).on("click", ".product-stockedit-button", function(e)
{
e.preventDefault();
var productId = $(e.currentTarget).attr("data-product-id");
var stockRowId = $(e.currentTarget).attr("data-id");
bootBoxModal('<iframe height="650px" class="embed-responsive" src="' + U("/stockedit?embedded&product=") + productId.toString() + '&stockRowId=' + stockRowId.toString() + '"></iframe>');
});
$(document).on("click", ".product-add-to-shopping-list-button", function(e)
{
e.preventDefault();
var productId = $(e.currentTarget).attr("data-product-id");
bootBoxModal('<iframe height="650px" class="embed-responsive" src="' + U("/shoppinglistitem/new?embedded&updateexistingproduct&product=") + productId.toString() + '"></iframe>');
});
function RefreshStockDetailRow(stockRowId)
{
Grocy.Api.Get("objects/stock/" + stockRowId,
function(result)
{
var stockRow = $('#stock-' + stockRowId + '-row');
var now = moment();
stockRow.removeClass("table-warning");
stockRow.removeClass("table-danger");
stockRow.removeClass("table-info");
stockRow.removeClass("d-none");
stockRow.removeAttr("style");
if (result == null || result.amount == 0)
{
stockRow.fadeOut(500, function()
{
//$(this).tooltip("hide");
$(this).addClass("d-none");
});
}
else
{
$('#stock-' + stockRowId + '-amount').parent().effect('highlight', { }, 500);
$('#stock-' + stockRowId + '-amount').fadeOut(500, function ()
{
$(this).text(result.amount).fadeIn(500);
});
$('#stock-' + stockRowId + '-best-before-date').parent().effect('highlight', { }, 500);
$('#stock-' + stockRowId + '-best-before-date').fadeOut(500, function()
{
$(this).text(result.best_before_date).fadeIn(500);
});
$('#stock-' + stockRowId + '-location').parent().effect('highlight', { }, 500);
$('#stock-' + stockRowId + '-location').fadeOut(500, function()
{
//TODO grab location name instead of id
$(this).text(result.location_id).fadeIn(500);
});
$('#stock-' + stockRowId + '-price').parent().effect('highlight', { }, 500);
$('#stock-' + stockRowId + '-price').fadeOut(500, function()
{
$(this).text(result.price).fadeIn(500);
});
$('#stock-' + stockRowId + '-purchased-date').parent().effect('highlight', { }, 500);
$('#stock-' + stockRowId + '-purchased-date').fadeOut(500, function()
{
$(this).text(result.purchased_date).fadeIn(500);
});
}
setTimeout(function()
{
RefreshContextualTimeago();
RefreshLocaleNumberDisplay();
}, 600);
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy();
console.error(xhr);
}
);
}
$(window).on("message", function(e)
{
var data = e.originalEvent.data;
if (data.Message === "StockDetailChanged")
{
RefreshStockDetailRow(data.Payload);
}
});

151
public/viewjs/stockedit.js Normal file
View File

@@ -0,0 +1,151 @@
$(document).ready(function() {
var stockRowId = GetUriParam('stockRowId');
Grocy.Api.Get("objects/stock/" + stockRowId,
function(stockEntry)
{
Grocy.Components.LocationPicker.SetId(stockEntry.location_id);
$('#amount').val(stockEntry.amount);
$('#price').val(stockEntry.price);
Grocy.Components.DateTimePicker.SetValue(stockEntry.best_before_date);
Grocy.Api.Get('stock/products/' + stockEntry.product_id,
function(productDetails)
{
$('#amount_qu_unit').text(productDetails.quantity_unit_stock.name);
if (productDetails.product.allow_partial_units_in_stock == 1)
{
$("#amount").attr("min", "0.01");
$("#amount").attr("step", "0.01");
$("#amount").parent().find(".invalid-feedback").text(__t('The amount cannot be lower than %1$s', 0.01.toLocaleString()));
}
else
{
$("#amount").attr("min", "1");
$("#amount").attr("step", "1");
$("#amount").parent().find(".invalid-feedback").text(__t('The amount cannot be lower than %1$s', '1'));
}
if (productDetails.product.enable_tare_weight_handling == 1)
{
$("#amount").attr("min", productDetails.product.tare_weight);
$("#amount").parent().find(".invalid-feedback").text(__t('The amount cannot be lower than %1$s', parseFloat(productDetails.product.tare_weight).toLocaleString({ minimumFractionDigits: 0, maximumFractionDigits: 2 })));
$("#tare-weight-handling-info").removeClass("d-none");
}
else
{
$("#tare-weight-handling-info").addClass("d-none");
}
},
function(xhr)
{
console.error(xhr);
}
);
},
function(xhr)
{
console.error(xhr);
}
);
} );
$('#save-stockedit-button').on('click', function(e)
{
e.preventDefault();
var jsonForm = $('#stockedit-form').serializeJSON();
Grocy.FrontendHelpers.BeginUiBusy("stockedit-form");
if (!jsonForm.price.toString().isEmpty())
{
price = parseFloat(jsonForm.price).toFixed(2);
}
var jsonData = { };
jsonData.amount = jsonForm.amount;
jsonData.best_before_date = Grocy.Components.DateTimePicker.GetValue();
if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
{
jsonData.location_id = Grocy.Components.LocationPicker.GetValue();
}
else
{
jsonData.location_id = 1;
}
jsonData.price = price;
var bookingResponse = null;
var stockRowId = GetUriParam('stockRowId');
jsonData.stock_row_id = stockRowId;
Grocy.Api.Put("stock", jsonData,
function(result)
{
var successMessage = __t('Stock entry successfully updated') + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(\'' + result.id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
window.parent.postMessage(WindowMessageBag("StockDetailChanged", stockRowId), Grocy.BaseUrl);
window.parent.postMessage(WindowMessageBag("ShowSuccessMessage", successMessage), Grocy.BaseUrl);
window.parent.postMessage(WindowMessageBag("Ready"), Grocy.BaseUrl);
window.parent.postMessage(WindowMessageBag("CloseAllModals"), Grocy.BaseUrl);
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy("stockedit-form");
console.error(xhr);
}
);
});
Grocy.FrontendHelpers.ValidateForm('stockedit-form');
$('#stockedit-form input').keyup(function (event)
{
Grocy.FrontendHelpers.ValidateForm('stockedit-form');
});
$('#stockedit-form input').keydown(function(event)
{
if (event.keyCode === 13) //Enter
{
event.preventDefault();
if (document.getElementById('stockedit-form').checkValidity() === false) //There is at least one validation error
{
return false;
}
else
{
$('#save-stockedit-button').click();
}
}
});
if (Grocy.Components.DateTimePicker)
{
Grocy.Components.DateTimePicker.GetInputElement().on('change', function(e)
{
Grocy.FrontendHelpers.ValidateForm('stockedit-form');
});
Grocy.Components.DateTimePicker.GetInputElement().on('keypress', function(e)
{
Grocy.FrontendHelpers.ValidateForm('stockedit-form');
});
}
function UndoStockBooking(bookingId)
{
Grocy.Api.Post('stock/bookings/' + bookingId.toString() + '/undo', { },
function(result)
{
toastr.success(__t("Booking successfully undone"));
},
function(xhr)
{
console.error(xhr);
}
);
};

View File

@@ -84,7 +84,7 @@ $(document).on('click', '.product-consume-button', function(e)
Grocy.Api.Get('stock/products/' + productId, Grocy.Api.Get('stock/products/' + productId,
function(result) function(result)
{ {
var toastMessage = __t('Removed %1$s of %2$s from stock', consumeAmount.toString() + " " + __n(consumeAmount, result.quantity_unit_stock.name, result.quantity_unit_stock.name_plural), result.product.name) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + bookingResponse.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>'; var toastMessage = __t('Removed %1$s of %2$s from stock', consumeAmount.toString() + " " + __n(consumeAmount, result.quantity_unit_stock.name, result.quantity_unit_stock.name_plural), result.product.name) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + bookingResponse.transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
if (wasSpoiled) if (wasSpoiled)
{ {
toastMessage += " (" + __t("Spoiled") + ")"; toastMessage += " (" + __t("Spoiled") + ")";
@@ -137,7 +137,7 @@ $(document).on('click', '.product-open-button', function(e)
} }
Grocy.FrontendHelpers.EndUiBusy(); Grocy.FrontendHelpers.EndUiBusy();
toastr.success(__t('Marked %1$s of %2$s as opened', 1 + " " + productQuName, productName) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + bookingResponse.id + ')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>'); toastr.success(__t('Marked %1$s of %2$s as opened', 1 + " " + productQuName, productName) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + bookingResponse.transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>');
RefreshStatistics(); RefreshStatistics();
RefreshProductRow(productId); RefreshProductRow(productId);
}, },
@@ -219,6 +219,30 @@ $(document).on("click", ".product-purchase-button", function(e)
}); });
}); });
$(document).on("click", ".product-transfer-button", function(e)
{
e.preventDefault();
var productId = $(e.currentTarget).attr("data-product-id");
bootbox.dialog({
message: '<iframe height="650px" class="embed-responsive" src="' + U("/transfer?embedded&product=") + productId.toString() + '"></iframe>',
size: 'large',
backdrop: true,
closeButton: false,
buttons: {
cancel: {
label: __t('Cancel'),
className: 'btn-secondary responsive-button',
callback: function()
{
bootbox.hideAll();
}
}
}
});
});
$(document).on("click", ".product-consume-custom-amount-button", function(e) $(document).on("click", ".product-consume-custom-amount-button", function(e)
{ {
e.preventDefault(); e.preventDefault();

441
public/viewjs/transfer.js Normal file
View File

@@ -0,0 +1,441 @@
$(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_from").val(locationId);
$("#location_id_from").trigger('change');
$("#use_specific_stock_entry").click();
$("#use_specific_stock_entry").trigger('change');
}
}
});
$('#save-transfer-button').on('click', function(e)
{
e.preventDefault();
var jsonForm = $('#transfer-form').serializeJSON();
Grocy.FrontendHelpers.BeginUiBusy("transfer-form");
var apiUrl = 'stock/products/' + jsonForm.product_id + '/transfer';
var jsonData = {};
jsonData.amount = jsonForm.amount;
jsonData.location_id_to = $("#location_id_to").val();
jsonData.location_id_from = $("#location_id_from").val();
if ($("#use_specific_stock_entry").is(":checked"))
{
jsonData.stock_entry_id = jsonForm.specific_stock_entry;
}
var bookingResponse = null;
Grocy.Api.Get('stock/products/' + jsonForm.product_id,
function(productDetails)
{
Grocy.Api.Post(apiUrl, jsonData,
function(result)
{
var addBarcode = GetUriParam('addbarcodetoselection');
bookingResponse = result;
if (addBarcode !== undefined)
{
var existingBarcodes = productDetails.product.barcode || '';
if (existingBarcodes.length === 0)
{
productDetails.product.barcode = addBarcode;
}
else
{
productDetails.product.barcode += ',' + addBarcode;
}
Grocy.Api.Put('objects/products/' + productDetails.product.id, productDetails.product,
function(result)
{
$("#flow-info-addbarcodetoselection").addClass("d-none");
$('#barcode-lookup-disabled-hint').addClass('d-none');
window.history.replaceState({ }, document.title, U("/transfer"));
},
function(xhr)
{
console.error(xhr);
}
);
}
if (productDetails.product.enable_tare_weight_handling == 1)
{
var successMessage = __t('Transfered %1$s of %2$s from %3$s to %4$s', 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,$('option:selected', "#location_id_from").text(), $('option:selected', "#location_id_to").text()) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + bookingResponse.transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
}
else
{
var successMessage =__t('Transfered %1$s of %2$s from %3$s to %4$s', Math.abs(jsonForm.amount) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name, $('option:selected', "#location_id_from").text(), $('option:selected', "#location_id_to").text()) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + bookingResponse.transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
}
if (GetUriParam("embedded") !== undefined)
{
window.parent.postMessage(WindowMessageBag("ProductChanged", jsonForm.product_id), Grocy.BaseUrl);
window.parent.postMessage(WindowMessageBag("ShowSuccessMessage", successMessage), Grocy.BaseUrl);
window.parent.postMessage(WindowMessageBag("CloseAllModals"), Grocy.BaseUrl);
}
else
{
Grocy.FrontendHelpers.EndUiBusy("transfer-form");
toastr.success(successMessage);
$("#specific_stock_entry").find("option").remove().end().append("<option></option>");
$("#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("<option></option>");
$("#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("<option></option>");
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("<option></option>");
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($("<option>", {
value: stockLocation.location_id,
text: stockLocation.location_name + " (" + __t("Default location") + ")"
}));
$("#location_id_from").val(productDetails.location.id);
$("#location_id_from").trigger('change');
setDefault = 1;
}
else
{
$("#location_id_from").append($("<option>", {
value: stockLocation.location_id,
text: stockLocation.location_name
}));
}
if (setDefault == 0)
{
$("#location_id_from").val(stockLocation.location_id);
$("#location_id_from").trigger('change');
}
});
},
function(xhr)
{
console.error(xhr);
}
);
if (productDetails.product.allow_partial_units_in_stock == 1)
{
$("#amount").attr("min", "0.01");
$("#amount").attr("step", "0.01");
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', 0.01.toLocaleString(), parseFloat(productDetails.stock_amount).toLocaleString()));
}
else
{
$("#amount").attr("min", "1");
$("#amount").attr("step", "1");
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", parseFloat(productDetails.stock_amount).toLocaleString()));
}
if (productDetails.product.enable_tare_weight_handling == 1)
{
$("#amount").attr("min", productDetails.product.tare_weight);
$("#tare-weight-handling-info").removeClass("d-none");
}
else
{
$("#tare-weight-handling-info").addClass("d-none");
}
if ((parseFloat(productDetails.stock_amount) || 0) === 0)
{
Grocy.Components.ProductPicker.Clear();
Grocy.FrontendHelpers.ValidateForm('transfer-form');
Grocy.Components.ProductPicker.ShowCustomError(__t('This product is not in stock'));
Grocy.Components.ProductPicker.GetInputElement().focus();
}
else
{
Grocy.Components.ProductPicker.HideCustomError();
Grocy.FrontendHelpers.ValidateForm('transfer-form');
$('#amount').focus();
}
},
function(xhr)
{
console.error(xhr);
}
);
}
});
$('#amount').val(Grocy.UserSettings.stock_default_transfer_amount);
Grocy.FrontendHelpers.ValidateForm('transfer-form');
$("#location_id_from").on('change', function(e)
{
var locationId = $(e.target).val();
var sumValue = 0;
var stockId = null;
if (locationId == $("#location_id_to").val())
{
$("#location_id_to").val("");
}
if (GetUriParam("embedded") !== undefined)
{
stockId = GetUriParam('stockId');
}
$("#specific_stock_entry").find("option").remove().end().append("<option></option>");
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($("<option>", {
value: stockEntry.stock_id,
amount: stockEntry.amount,
text: __t("Amount: %1$s; Expires on %2$s; Bought on %3$s", stockEntry.amount, moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD")) + "; " + openTxt
}));
if (stockEntry.stock_id == stockId)
{
$("#specific_stock_entry").val(stockId);
}
sumValue = sumValue + parseFloat(stockEntry.amount);
}
});
$("#amount").attr("max", sumValue);
if (sumValue == 0)
{
$("#amount").parent().find(".invalid-feedback").text(__t('There are no units available at this location'));
}
else
{
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", sumValue));
}
},
function(xhr)
{
console.error(xhr);
}
);
}
});
$("#location_id_to").on('change', function(e)
{
var locationId = $(e.target).val();
if (locationId == $("#location_id_from").val())
{
$("#location_id_to").parent().find(".invalid-feedback").text(__t('This cannot be the same as the "From" location'));
$("#location_id_to").val("");
}
});
$('#amount').on('focus', function(e)
{
$(this).select();
});
$('#transfer-form input').keyup(function(event)
{
Grocy.FrontendHelpers.ValidateForm('transfer-form');
});
$('#transfer-form select').change(function(event)
{
Grocy.FrontendHelpers.ValidateForm('transfer-form');
});
$('#transfer-form input').keydown(function(event)
{
if (event.keyCode === 13) //Enter
{
event.preventDefault();
if (document.getElementById('transfer-form').checkValidity() === false) //There is at least one validation error
{
return false;
}
else
{
$('#save-transfer-button').click();
}
}
});
$("#specific_stock_entry").on("change", function(e)
{
if ($(e.target).val() == "")
{
var sumValue = 0;
Grocy.Api.Get("stock/products/" + Grocy.Components.ProductPicker.GetValue() + '/entries',
function(stockEntries)
{
stockEntries.forEach(stockEntry =>
{
if (stockEntry.location_id == $("#location_id_from").val() || stockEntry.location_id == "")
{
sumValue = sumValue + parseFloat(stockEntry.amount);
}
});
$("#amount").attr("max", sumValue);
if (sumValue == 0)
{
$("#amount").parent().find(".invalid-feedback").text(__t('There are no units available at this location'));
}
else
{
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", sumValue));
}
},
function(xhr)
{
console.error(xhr);
}
);
}
else
{
$("#amount").parent().find(".invalid-feedback").text(__t('The amount must be between %1$s and %2$s', "1", $('option:selected', this).attr('amount')));
$("#amount").attr("max", $('option:selected', this).attr('amount'));
}
});
$("#use_specific_stock_entry").on("change", function()
{
var value = $(this).is(":checked");
if (value)
{
$("#specific_stock_entry").removeAttr("disabled");
$("#specific_stock_entry").attr("required", "");
}
else
{
$("#specific_stock_entry").attr("disabled", "");
$("#specific_stock_entry").removeAttr("required");
$("#specific_stock_entry").val("");
$("#location_id_from").trigger('change');
}
Grocy.FrontendHelpers.ValidateForm("transfer-form");
});
function UndoStockBooking(bookingId)
{
Grocy.Api.Post('stock/bookings/' + bookingId.toString() + '/undo', { },
function(result)
{
toastr.success(__t("Booking successfully undone"));
},
function(xhr)
{
console.error(xhr);
}
);
};
function UndoStockTransaction(transactionId)
{
Grocy.Api.Post('stock/transactions/' + transactionId.toString() + '/undo', { },
function (result)
{
toastr.success(__t("Transaction successfully undone"));
},
function (xhr)
{
console.error(xhr);
}
);
};

View File

@@ -33,9 +33,12 @@ $app->group('', function()
if (GROCY_FEATURE_FLAG_STOCK) if (GROCY_FEATURE_FLAG_STOCK)
{ {
$this->get('/stockoverview', '\Grocy\Controllers\StockController:Overview'); $this->get('/stockoverview', '\Grocy\Controllers\StockController:Overview');
$this->get('/stockdetail', '\Grocy\Controllers\StockController:Detail');
$this->get('/purchase', '\Grocy\Controllers\StockController:Purchase'); $this->get('/purchase', '\Grocy\Controllers\StockController:Purchase');
$this->get('/consume', '\Grocy\Controllers\StockController:Consume'); $this->get('/consume', '\Grocy\Controllers\StockController:Consume');
$this->get('/transfer', '\Grocy\Controllers\StockController:Transfer');
$this->get('/inventory', '\Grocy\Controllers\StockController:Inventory'); $this->get('/inventory', '\Grocy\Controllers\StockController:Inventory');
$this->get('/stockedit', '\Grocy\Controllers\StockController:StockEdit');
$this->get('/products', '\Grocy\Controllers\StockController:ProductsList'); $this->get('/products', '\Grocy\Controllers\StockController:ProductsList');
$this->get('/product/{productId}', '\Grocy\Controllers\StockController:ProductEditForm'); $this->get('/product/{productId}', '\Grocy\Controllers\StockController:ProductEditForm');
$this->get('/stocksettings', '\Grocy\Controllers\StockController:StockSettings'); $this->get('/stocksettings', '\Grocy\Controllers\StockController:StockSettings');
@@ -158,21 +161,27 @@ $app->group('/api', function()
if (GROCY_FEATURE_FLAG_STOCK) if (GROCY_FEATURE_FLAG_STOCK)
{ {
$this->get('/stock', '\Grocy\Controllers\StockApiController:CurrentStock'); $this->get('/stock', '\Grocy\Controllers\StockApiController:CurrentStock');
$this->put('/stock', '\Grocy\Controllers\StockApiController:EditStock');
$this->get('/stock/volatile', '\Grocy\Controllers\StockApiController:CurrentVolatilStock'); $this->get('/stock/volatile', '\Grocy\Controllers\StockApiController:CurrentVolatilStock');
$this->get('/stock/products/{productId}', '\Grocy\Controllers\StockApiController:ProductDetails'); $this->get('/stock/products/{productId}', '\Grocy\Controllers\StockApiController:ProductDetails');
$this->get('/stock/products/{productId}/entries', '\Grocy\Controllers\StockApiController:ProductStockEntries'); $this->get('/stock/products/{productId}/entries', '\Grocy\Controllers\StockApiController:ProductStockEntries');
$this->get('/stock/products/{productId}/locations', '\Grocy\Controllers\StockApiController:ProductStockLocations');
$this->get('/stock/products/{productId}/price-history', '\Grocy\Controllers\StockApiController:ProductPriceHistory'); $this->get('/stock/products/{productId}/price-history', '\Grocy\Controllers\StockApiController:ProductPriceHistory');
$this->post('/stock/products/{productId}/add', '\Grocy\Controllers\StockApiController:AddProduct'); $this->post('/stock/products/{productId}/add', '\Grocy\Controllers\StockApiController:AddProduct');
$this->post('/stock/products/{productId}/consume', '\Grocy\Controllers\StockApiController:ConsumeProduct'); $this->post('/stock/products/{productId}/consume', '\Grocy\Controllers\StockApiController:ConsumeProduct');
$this->post('/stock/products/{productId}/transfer', '\Grocy\Controllers\StockApiController:TransferProduct');
$this->post('/stock/products/{productId}/inventory', '\Grocy\Controllers\StockApiController:InventoryProduct'); $this->post('/stock/products/{productId}/inventory', '\Grocy\Controllers\StockApiController:InventoryProduct');
$this->post('/stock/products/{productId}/open', '\Grocy\Controllers\StockApiController:OpenProduct'); $this->post('/stock/products/{productId}/open', '\Grocy\Controllers\StockApiController:OpenProduct');
$this->get('/stock/products/by-barcode/{barcode}', '\Grocy\Controllers\StockApiController:ProductDetailsByBarcode'); $this->get('/stock/products/by-barcode/{barcode}', '\Grocy\Controllers\StockApiController:ProductDetailsByBarcode');
$this->post('/stock/products/by-barcode/{barcode}/add', '\Grocy\Controllers\StockApiController:AddProductByBarcode'); $this->post('/stock/products/by-barcode/{barcode}/add', '\Grocy\Controllers\StockApiController:AddProductByBarcode');
$this->post('/stock/products/by-barcode/{barcode}/consume', '\Grocy\Controllers\StockApiController:ConsumeProductByBarcode'); $this->post('/stock/products/by-barcode/{barcode}/consume', '\Grocy\Controllers\StockApiController:ConsumeProductByBarcode');
$this->post('/stock/products/by-barcode/{barcode}/transfer', '\Grocy\Controllers\StockApiController:TransferProductByBarcode');
$this->post('/stock/products/by-barcode/{barcode}/inventory', '\Grocy\Controllers\StockApiController:InventoryProductByBarcode'); $this->post('/stock/products/by-barcode/{barcode}/inventory', '\Grocy\Controllers\StockApiController:InventoryProductByBarcode');
$this->post('/stock/products/by-barcode/{barcode}/open', '\Grocy\Controllers\StockApiController:OpenProductByBarcode'); $this->post('/stock/products/by-barcode/{barcode}/open', '\Grocy\Controllers\StockApiController:OpenProductByBarcode');
$this->get('/stock/bookings/{bookingId}', '\Grocy\Controllers\StockApiController:StockBooking'); $this->get('/stock/bookings/{bookingId}', '\Grocy\Controllers\StockApiController:StockBooking');
$this->post('/stock/bookings/{bookingId}/undo', '\Grocy\Controllers\StockApiController:UndoBooking'); $this->post('/stock/bookings/{bookingId}/undo', '\Grocy\Controllers\StockApiController:UndoBooking');
$this->get('/stock/transactions/{transactionId}', '\Grocy\Controllers\StockApiController:StockTransactions');
$this->post('/stock/transactions/{transactionId}/undo', '\Grocy\Controllers\StockApiController:UndoTransaction');
$this->get('/stock/barcodes/external-lookup/{barcode}', '\Grocy\Controllers\StockApiController:ExternalBarcodeLookup'); $this->get('/stock/barcodes/external-lookup/{barcode}', '\Grocy\Controllers\StockApiController:ExternalBarcodeLookup');
} }

View File

@@ -6,7 +6,11 @@ class StockService extends BaseService
{ {
const TRANSACTION_TYPE_PURCHASE = 'purchase'; const TRANSACTION_TYPE_PURCHASE = 'purchase';
const TRANSACTION_TYPE_CONSUME = 'consume'; const TRANSACTION_TYPE_CONSUME = 'consume';
const TRANSACTION_TYPE_TRANSFER_FROM = 'transfer_from';
const TRANSACTION_TYPE_TRANSFER_TO = 'transfer_to';
const TRANSACTION_TYPE_INVENTORY_CORRECTION = 'inventory-correction'; const TRANSACTION_TYPE_INVENTORY_CORRECTION = 'inventory-correction';
const TRANSACTION_TYPE_STOCK_EDIT_NEW = 'stock-edit-new';
const TRANSACTION_TYPE_STOCK_EDIT_OLD = 'stock-edit-old';
const TRANSACTION_TYPE_PRODUCT_OPENED = 'product-opened'; const TRANSACTION_TYPE_PRODUCT_OPENED = 'product-opened';
public function GetCurrentStock($includeNotInStockButMissingProducts = false) public function GetCurrentStock($includeNotInStockButMissingProducts = false)
@@ -55,6 +59,11 @@ class StockService extends BaseService
return $this->DatabaseService->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ); return $this->DatabaseService->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ);
} }
public function GetProductStockLocations($productId)
{
return $this->Database->stock_current_locations()->where('product_id', $productId)->fetchAll();
}
public function GetProductIdFromBarcode(string $barcode) public function GetProductIdFromBarcode(string $barcode)
{ {
$potentialProduct = $this->Database->products()->where("',' || barcode || ',' LIKE '%,' || :1 || ',%' AND IFNULL(barcode, '') != ''", $barcode)->limit(1)->fetch(); $potentialProduct = $this->Database->products()->where("',' || barcode || ',' LIKE '%,' || :1 || ',%' AND IFNULL(barcode, '') != ''", $barcode)->limit(1)->fetch();
@@ -176,7 +185,13 @@ class StockService extends BaseService
} }
} }
public function AddProduct(int $productId, float $amount, $bestBeforeDate, $transactionType, $purchasedDate, $price, $locationId = null) public function GetProductStockEntriesForLocation($productId, $locationId, $excludeOpened = false)
{
$stockEntries = $this->GetProductStockEntries($productId, $excludeOpened);
return FindAllObjectsInArrayByPropertyValue($stockEntries, 'location_id', $locationId);
}
public function AddProduct(int $productId, float $amount, $bestBeforeDate, $transactionType, $purchasedDate, $price, $locationId = null, &$transactionId = null)
{ {
if (!$this->ProductExists($productId)) if (!$this->ProductExists($productId))
{ {
@@ -216,6 +231,11 @@ class StockService extends BaseService
if ($transactionType === self::TRANSACTION_TYPE_PURCHASE || $transactionType === self::TRANSACTION_TYPE_INVENTORY_CORRECTION) if ($transactionType === self::TRANSACTION_TYPE_PURCHASE || $transactionType === self::TRANSACTION_TYPE_INVENTORY_CORRECTION)
{ {
if ($transactionId === null)
{
$transactionId = uniqid();
}
$stockId = uniqid(); $stockId = uniqid();
$logRow = $this->Database->stock_log()->createRow(array( $logRow = $this->Database->stock_log()->createRow(array(
@@ -226,7 +246,8 @@ class StockService extends BaseService
'stock_id' => $stockId, 'stock_id' => $stockId,
'transaction_type' => $transactionType, 'transaction_type' => $transactionType,
'price' => $price, 'price' => $price,
'location_id' => $locationId 'location_id' => $locationId,
'transaction_id' => $transactionId
)); ));
$logRow->save(); $logRow->save();
@@ -251,13 +272,18 @@ class StockService extends BaseService
} }
} }
public function ConsumeProduct(int $productId, float $amount, bool $spoiled, $transactionType, $specificStockEntryId = 'default', $recipeId = null) public function ConsumeProduct(int $productId, float $amount, bool $spoiled, $transactionType, $specificStockEntryId = 'default', $recipeId = null, $locationId = null, &$transactionId = null)
{ {
if (!$this->ProductExists($productId)) if (!$this->ProductExists($productId))
{ {
throw new \Exception('Product does not exist'); throw new \Exception('Product does not exist');
} }
if ($locationId !== null & !$this->LocationExists($locationId))
{
throw new \Exception('Location does not exist');
}
// Tare weight handling // Tare weight handling
// The given amount is the new total amount including the container weight (gross) // The given amount is the new total amount including the container weight (gross)
// The amount to be posted needs to be the absolute value of the given amount - stock amount - tare weight // The amount to be posted needs to be the absolute value of the given amount - stock amount - tare weight
@@ -273,13 +299,21 @@ class StockService extends BaseService
} }
if ($transactionType === self::TRANSACTION_TYPE_CONSUME || $transactionType === self::TRANSACTION_TYPE_INVENTORY_CORRECTION) if ($transactionType === self::TRANSACTION_TYPE_CONSUME || $transactionType === self::TRANSACTION_TYPE_INVENTORY_CORRECTION)
{
if ($locationId === null) // Consume from any location
{ {
$productStockAmount = $this->Database->stock()->where('product_id', $productId)->sum('amount'); $productStockAmount = $this->Database->stock()->where('product_id', $productId)->sum('amount');
$potentialStockEntries = $this->GetProductStockEntries($productId); $potentialStockEntries = $this->GetProductStockEntries($productId);
}
else // Consume only from the supplied location
{
$productStockAmount = $this->Database->stock()->where('product_id = :1 AND location_id = :2', $productId, $locationId)->sum('amount');
$potentialStockEntries = $this->GetProductStockEntriesForLocation($productId, $locationId);
}
if ($amount > $productStockAmount) if ($amount > $productStockAmount)
{ {
throw new \Exception('Amount to be consumed cannot be > current stock amount'); throw new \Exception('Amount to be consumed cannot be > current stock amount (if supplied, at the desired location)');
} }
if ($specificStockEntryId !== 'default') if ($specificStockEntryId !== 'default')
@@ -287,6 +321,11 @@ class StockService extends BaseService
$potentialStockEntries = FindAllObjectsInArrayByPropertyValue($potentialStockEntries, 'stock_id', $specificStockEntryId); $potentialStockEntries = FindAllObjectsInArrayByPropertyValue($potentialStockEntries, 'stock_id', $specificStockEntryId);
} }
if ($transactionId === null)
{
$transactionId = uniqid();
}
foreach ($potentialStockEntries as $stockEntry) foreach ($potentialStockEntries as $stockEntry)
{ {
if ($amount == 0) if ($amount == 0)
@@ -307,7 +346,8 @@ class StockService extends BaseService
'transaction_type' => $transactionType, 'transaction_type' => $transactionType,
'price' => $stockEntry->price, 'price' => $stockEntry->price,
'opened_date' => $stockEntry->opened_date, 'opened_date' => $stockEntry->opened_date,
'recipe_id' => $recipeId 'recipe_id' => $recipeId,
'transaction_id' => $transactionId
)); ));
$logRow->save(); $logRow->save();
@@ -330,7 +370,8 @@ class StockService extends BaseService
'transaction_type' => $transactionType, 'transaction_type' => $transactionType,
'price' => $stockEntry->price, 'price' => $stockEntry->price,
'opened_date' => $stockEntry->opened_date, 'opened_date' => $stockEntry->opened_date,
'recipe_id' => $recipeId 'recipe_id' => $recipeId,
'transaction_id' => $transactionId
)); ));
$logRow->save(); $logRow->save();
@@ -350,6 +391,219 @@ class StockService extends BaseService
} }
} }
public function TransferProduct(int $productId, float $amount, int $locationIdFrom, int $locationIdTo, $specificStockEntryId = 'default', &$transactionId = null)
{
if (!$this->ProductExists($productId))
{
throw new \Exception('Product does not exist');
}
if (!$this->LocationExists($locationIdFrom))
{
throw new \Exception('Source location does not exist');
}
if (!$this->LocationExists($locationIdTo))
{
throw new \Exception('Destination location does not exist');
}
// Tare weight handling
// The given amount is the new total amount including the container weight (gross)
// The amount to be posted needs to be the absolute value of the given amount - stock amount - tare weight
$productDetails = (object)$this->GetProductDetails($productId);
if ($productDetails->product->enable_tare_weight_handling == 1)
{
// Hard fail for now, as we not yet support transfering tare weight enabled products
throw new \Exception('Transfering tare weight enabled products is not yet possible');
if ($amount < floatval($productDetails->product->tare_weight))
{
throw new \Exception('The amount cannot be lower than the defined tare weight');
}
$amount = abs($amount - floatval($productDetails->stock_amount) - floatval($productDetails->product->tare_weight));
}
$productStockAmountAtFromLocation = $this->Database->stock()->where('product_id = :1 AND location_id = :2', $productId, $locationIdFrom)->sum('amount');
$potentialStockEntriesAtFromLocation = $this->GetProductStockEntriesForLocation($productId, $locationIdFrom);
if ($amount > $productStockAmountAtFromLocation)
{
throw new \Exception('Amount to be transfered cannot be > current stock amount at the source location');
}
if ($specificStockEntryId !== 'default')
{
$potentialStockEntriesAtFromLocation = FindAllObjectsInArrayByPropertyValue($potentialStockEntriesAtFromLocation, 'stock_id', $specificStockEntryId);
}
if ($transactionId === null)
{
$transactionId = uniqid();
}
foreach ($potentialStockEntriesAtFromLocation as $stockEntry)
{
if ($amount == 0)
{
break;
}
$correlationId = uniqid();
if ($amount >= $stockEntry->amount) // Take the whole stock entry
{
$logRowForLocationFrom = $this->Database->stock_log()->createRow(array(
'product_id' => $stockEntry->product_id,
'amount' => $stockEntry->amount * -1,
'best_before_date' => $stockEntry->best_before_date,
'purchased_date' => $stockEntry->purchased_date,
'stock_id' => $stockEntry->stock_id,
'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_FROM,
'price' => $stockEntry->price,
'opened_date' => $stockEntry->opened_date,
'location_id' => $stockEntry->location_id,
'correlation_id' => $correlationId,
'transaction_Id' => $transactionId
));
$logRowForLocationFrom->save();
$logRowForLocationTo = $this->Database->stock_log()->createRow(array(
'product_id' => $stockEntry->product_id,
'amount' => $stockEntry->amount,
'best_before_date' => $stockEntry->best_before_date,
'purchased_date' => $stockEntry->purchased_date,
'stock_id' => $stockEntry->stock_id,
'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_TO,
'price' => $stockEntry->price,
'opened_date' => $stockEntry->opened_date,
'location_id' => $locationIdTo,
'correlation_id' => $correlationId,
'transaction_Id' => $transactionId
));
$logRowForLocationTo->save();
$stockEntry->update(array(
'location_id' => $locationIdTo
));
$amount -= $stockEntry->amount;
}
else // Stock entry amount is > than needed amount -> split the stock entry resp. update the amount
{
$restStockAmount = $stockEntry->amount - $amount;
$logRowForLocationFrom = $this->Database->stock_log()->createRow(array(
'product_id' => $stockEntry->product_id,
'amount' => $amount * -1,
'best_before_date' => $stockEntry->best_before_date,
'purchased_date' => $stockEntry->purchased_date,
'stock_id' => $stockEntry->stock_id,
'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_FROM,
'price' => $stockEntry->price,
'opened_date' => $stockEntry->opened_date,
'location_id' => $stockEntry->location_id,
'correlation_id' => $correlationId,
'transaction_Id' => $transactionId
));
$logRowForLocationFrom->save();
$logRowForLocationTo = $this->Database->stock_log()->createRow(array(
'product_id' => $stockEntry->product_id,
'amount' => $amount,
'best_before_date' => $stockEntry->best_before_date,
'purchased_date' => $stockEntry->purchased_date,
'stock_id' => $stockEntry->stock_id,
'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_TO,
'price' => $stockEntry->price,
'opened_date' => $stockEntry->opened_date,
'location_id' => $locationIdTo,
'correlation_id' => $correlationId,
'transaction_Id' => $transactionId
));
$logRowForLocationTo->save();
// This is the existing stock entry -> remains at the source location with the rest amount
$stockEntry->update(array(
'amount' => $restStockAmount
));
// The transfered amount gets into a new stock entry
$stockEntryNew = $this->Database->stock()->createRow(array(
'product_id' => $stockEntry->product_id,
'amount' => $amount,
'best_before_date' => $stockEntry->best_before_date,
'purchased_date' => $stockEntry->purchased_date,
'stock_id' => $stockEntry->stock_id,
'price' => $stockEntry->price,
'location_id' => $locationIdTo,
'open' => $stockEntry->open,
'opened_date' => $stockEntry->opened_date
));
$stockEntryNew->save();
$amount = 0;
}
}
return $this->Database->lastInsertId();
}
public function EditStock(int $stockRowId, int $amount, $bestBeforeDate, $locationId, $price)
{
$stockRow = $this->Database->stock()->where('id = :1', $stockRowId)->fetch();
if ($stockRow === null)
{
throw new \Exception('Stock does not exist');
}
$correlationId = uniqid();
$transactionId = uniqid();
$logOldRowForStockUpdate = $this->Database->stock_log()->createRow(array(
'product_id' => $stockRow->product_id,
'amount' => $stockRow->amount,
'best_before_date' => $stockRow->best_before_date,
'purchased_date' => $stockRow->purchased_date,
'stock_id' => $stockRow->stock_id,
'transaction_type' => self::TRANSACTION_TYPE_STOCK_EDIT_OLD,
'price' => $stockRow->price,
'opened_date' => $stockRow->opened_date,
'location_id' => $stockRow->location_id,
'correlation_id' => $correlationId,
'transaction_id' => $transactionId,
'stock_row_id' => $stockRow->id
));
$logOldRowForStockUpdate->save();
$stockRow->update(array(
'amount' => $amount,
'price' => $price,
'best_before_date' => $bestBeforeDate,
'location_id' => $locationId
));
$logNewRowForStockUpdate = $this->Database->stock_log()->createRow(array(
'product_id' => $stockRow->product_id,
'amount' => $amount,
'best_before_date' => $bestBeforeDate,
'purchased_date' => $stockRow->purchased_date,
'stock_id' => $stockRow->stock_id,
'transaction_type' => self::TRANSACTION_TYPE_STOCK_EDIT_NEW,
'price' => $price,
'opened_date' => $stockRow->opened_date,
'location_id' => $locationId,
'correlation_id' => $correlationId,
'transaction_id' => $transactionId,
'stock_row_id' => $stockRow->id
));
$logNewRowForStockUpdate->save();
$returnValue = $this->Database->lastInsertId();
return $returnValue;
}
public function InventoryProduct(int $productId, int $newAmount, $bestBeforeDate, $locationId = null, $price = null) public function InventoryProduct(int $productId, int $newAmount, $bestBeforeDate, $locationId = null, $price = null)
{ {
if (!$this->ProductExists($productId)) if (!$this->ProductExists($productId))
@@ -608,6 +862,12 @@ class StockService extends BaseService
return $productRow !== null; return $productRow !== null;
} }
private function LocationExists($locationId)
{
$locationRow = $this->Database->locations()->where('id = :1', $locationId)->fetch();
return $locationRow !== null;
}
private function ShoppingListExists($listId) private function ShoppingListExists($listId)
{ {
$shoppingListRow = $this->Database->shopping_lists()->where('id = :1', $listId)->fetch(); $shoppingListRow = $this->Database->shopping_lists()->where('id = :1', $listId)->fetch();
@@ -654,7 +914,7 @@ class StockService extends BaseService
return $pluginOutput; return $pluginOutput;
} }
public function UndoBooking($bookingId) public function UndoBooking($bookingId, $skipCorrelatedBookings = false)
{ {
$logRow = $this->Database->stock_log()->where('id = :1 AND undone = 0', $bookingId)->fetch(); $logRow = $this->Database->stock_log()->where('id = :1 AND undone = 0', $bookingId)->fetch();
if ($logRow == null) if ($logRow == null)
@@ -662,7 +922,18 @@ class StockService extends BaseService
throw new \Exception('Booking does not exist or was already undone'); throw new \Exception('Booking does not exist or was already undone');
} }
$hasSubsequentBookings = $this->Database->stock_log()->where('stock_id = :1 AND id != :2 AND id > :2', $logRow->stock_id, $logRow->id)->count() > 0; // Undo all correlated bookings first, in order from newest first to the oldest
if (!$skipCorrelatedBookings && !empty($logRow->correlation_id))
{
$correlatedBookings = $this->Database->stock_log()->where('undone = 0 AND correlation_id = :1', $logRow->correlation_id)->orderBy('id', 'DESC')->fetchAll();
foreach ($correlatedBookings as $correlatedBooking)
{
$this->UndoBooking($correlatedBooking->id, true);
}
return;
}
$hasSubsequentBookings = $this->Database->stock_log()->where('stock_id = :1 AND id != :2 AND (correlation_id is not null OR correlation_id != :3) AND id > :2 AND undone = 0', $logRow->stock_id, $logRow->id, $logRow->correlation_id)->count() > 0;
if ($hasSubsequentBookings) if ($hasSubsequentBookings)
{ {
throw new \Exception('Booking has subsequent dependent bookings, undo not possible'); throw new \Exception('Booking has subsequent dependent bookings, undo not possible');
@@ -700,6 +971,60 @@ class StockService extends BaseService
'undone_timestamp' => date('Y-m-d H:i:s') 'undone_timestamp' => date('Y-m-d H:i:s')
)); ));
} }
elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_TRANSFER_TO)
{
$stockRow = $this->Database->stock()->where('stock_id = :1 AND location_id = :2', $logRow->stock_id, $logRow->location_id)->fetch();
if ($stockRow === null)
{
throw new \Exception('Booking does not exist or was already undone');
}
$newAmount = $stockRow->amount - $logRow->amount;
if ($newAmount == 0)
{
$stockRow->delete();
} else {
// Remove corresponding amount back to stock
$stockRow->update(array(
'amount' => $newAmount
));
}
// Update log entry
$logRow->update(array(
'undone' => 1,
'undone_timestamp' => date('Y-m-d H:i:s')
));
}
elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_TRANSFER_FROM)
{
// Add corresponding amount back to stock or
// create a row if missing
$stockRow = $this->Database->stock()->where('stock_id = :1 AND location_id = :2', $logRow->stock_id, $logRow->location_id)->fetch();
if ($stockRow === null)
{
$stockRow = $this->Database->stock()->createRow(array(
'product_id' => $logRow->product_id,
'amount' => $logRow->amount * -1,
'best_before_date' => $logRow->best_before_date,
'purchased_date' => $logRow->purchased_date,
'stock_id' => $logRow->stock_id,
'price' => $logRow->price,
'opened_date' => $logRow->opened_date
));
$stockRow->save();
} else {
$stockRow->update(array(
'amount' => $stockRow->amount - $logRow->amount
));
}
// Update log entry
$logRow->update(array(
'undone' => 1,
'undone_timestamp' => date('Y-m-d H:i:s')
));
}
elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_PRODUCT_OPENED) elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_PRODUCT_OPENED)
{ {
// Remove opened flag from corresponding log entry // Remove opened flag from corresponding log entry
@@ -715,9 +1040,55 @@ class StockService extends BaseService
'undone_timestamp' => date('Y-m-d H:i:s') 'undone_timestamp' => date('Y-m-d H:i:s')
)); ));
} }
elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_STOCK_EDIT_NEW)
{
// Update log entry, no action needed
$logRow->update(array(
'undone' => 1,
'undone_timestamp' => date('Y-m-d H:i:s')
));
}
elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_STOCK_EDIT_OLD)
{
// Make sure there is a stock row still
$stockRow = $this->Database->stock()->where('id = :1', $logRow->stock_row_id)->fetch();
if ($stockRow == null)
{
throw new \Exception('Booking does not exist or was already undone');
}
$stockRow->update(array(
'amount' => $logRow->amount,
'best_before_date' => $logRow->best_before_date,
'purchased_date' => $logRow->purchased_date,
'price' => $logRow->price,
'location_id' => $logRow->location_id
));
// Update log entry
$logRow->update(array(
'undone' => 1,
'undone_timestamp' => date('Y-m-d H:i:s')
));
}
else else
{ {
throw new \Exception('This booking cannot be undone'); throw new \Exception('This booking cannot be undone');
} }
} }
public function UndoTransaction($transactionId)
{
$transactionBookings = $this->Database->stock_log()->where('undone = 0 AND transaction_id = :1', $transactionId)->orderBy('id', 'DESC')->fetchAll();
if (count($transactionBookings) === 0)
{
throw new \Exception('This transaction was not found or already undone');
}
foreach ($transactionBookings as $transactionBooking)
{
$this->UndoBooking($transactionBooking->id, true);
}
}
} }

View File

@@ -23,7 +23,7 @@
<strong>{{ $__t('Stock amount') . ' / ' . $__t('Quantity unit') }}:</strong> <span id="productcard-product-stock-amount" class="locale-number locale-number-quantity-amount"></span> <span id="productcard-product-stock-qu-name"></span> <span id="productcard-product-stock-opened-amount" class="small font-italic locale-number locale-number-quantity-amount"></span> <strong>{{ $__t('Stock amount') . ' / ' . $__t('Quantity unit') }}:</strong> <span id="productcard-product-stock-amount" class="locale-number locale-number-quantity-amount"></span> <span id="productcard-product-stock-qu-name"></span> <span id="productcard-product-stock-opened-amount" class="small font-italic locale-number locale-number-quantity-amount"></span>
<span id="productcard-aggregated-amounts" class="pl-2 text-secondary d-none"><i class="fas fa-custom-sigma-sign"></i> <span id="productcard-product-stock-amount-aggregated" class="locale-number locale-number-quantity-amount"></span> <span id="productcard-product-stock-qu-name-aggregated"></span> <span id="productcard-product-stock-opened-amount-aggregated locale-number locale-number-quantity-amount" class="small font-italic"></span></span><br> <span id="productcard-aggregated-amounts" class="pl-2 text-secondary d-none"><i class="fas fa-custom-sigma-sign"></i> <span id="productcard-product-stock-amount-aggregated" class="locale-number locale-number-quantity-amount"></span> <span id="productcard-product-stock-qu-name-aggregated"></span> <span id="productcard-product-stock-opened-amount-aggregated locale-number locale-number-quantity-amount" class="small font-italic"></span></span><br>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)<strong>{{ $__t('Location') }}:</strong> <span id="productcard-product-location"></span><br>@endif @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)<strong>{{ $__t('Default location') }}:</strong> <span id="productcard-product-location"></span><br>@endif
<strong>{{ $__t('Last purchased') }}:</strong> <span id="productcard-product-last-purchased"></span> <time id="productcard-product-last-purchased-timeago" class="timeago timeago-contextual"></time><br> <strong>{{ $__t('Last purchased') }}:</strong> <span id="productcard-product-last-purchased"></span> <time id="productcard-product-last-purchased-timeago" class="timeago timeago-contextual"></time><br>
<strong>{{ $__t('Last used') }}:</strong> <span id="productcard-product-last-used"></span> <time id="productcard-product-last-used-timeago" class="timeago timeago-contextual"></time><br> <strong>{{ $__t('Last used') }}:</strong> <span id="productcard-product-last-used"></span> <time id="productcard-product-last-used-timeago" class="timeago timeago-contextual"></time><br>
@if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)<strong>{{ $__t('Last price') }}:</strong> <span id="productcard-product-last-price"></span><br>@endif @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)<strong>{{ $__t('Last price') }}:</strong> <span id="productcard-product-last-price"></span><br>@endif

View File

@@ -22,11 +22,33 @@
'label' => 'Amount', 'label' => 'Amount',
'hintId' => 'amount_qu_unit', 'hintId' => 'amount_qu_unit',
'min' => 1, 'min' => 1,
'value' => 1, 'value' => 0,
'invalidFeedback' => $__t('The amount cannot be lower than %s', '1'), 'invalidFeedback' => $__t('The amount cannot be lower than %s', '1'),
'additionalHtmlContextHelp' => '<div id="tare-weight-handling-info" class="text-info font-italic d-none">' . $__t('Tare weight handling enabled - please weigh the whole container, the amount to be posted will be automatically calculcated') . '</div>' 'additionalHtmlContextHelp' => '<div id="tare-weight-handling-info" class="text-info font-italic d-none">' . $__t('Tare weight handling enabled - please weigh the whole container, the amount to be posted will be automatically calculcated') . '</div>'
)) ))
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
@php /*@include('components.locationpicker', array(
'id' => 'location_id',
'locations' => $locations,
'isRequired' => true,
'label' => 'Location'
))*/ @endphp
<div class="form-group">
<label for="location_id">{{ $__t('Location') }}</label>
<select required class="form-control location-combobox" id="location_id" name="location_id">
<option></option>
@foreach($locations as $location)
<option value="{{ $location->id }}">{{ $location->name }}</option>
@endforeach
</select>
<div class="invalid-feedback">{{ $__t('A location is required') }}</div>
</div>
@else
<input type="hidden" name="location_id" id="location_id" value="1">
@endif
<div class="form-group"> <div class="form-group">
<label for="use_specific_stock_entry"> <label for="use_specific_stock_entry">
<input type="checkbox" id="use_specific_stock_entry" name="use_specific_stock_entry"> {{ $__t('Use a specific stock item') }} <input type="checkbox" id="use_specific_stock_entry" name="use_specific_stock_entry"> {{ $__t('Use a specific stock item') }}

View File

@@ -59,7 +59,7 @@
'id' => 'price', 'id' => 'price',
'label' => 'Price', 'label' => 'Price',
'min' => 0, 'min' => 0,
'step' => 0.0001, 'step' => 0.01,
'value' => '', 'value' => '',
'hint' => $__t('in %s per purchase quantity unit', GROCY_CURRENCY), 'hint' => $__t('in %s per purchase quantity unit', GROCY_CURRENCY),
'additionalHtmlContextHelp' => '<br><span class="small text-muted">' . $__t('This will apply to added products') . '</span>', 'additionalHtmlContextHelp' => '<br><span class="small text-muted">' . $__t('This will apply to added products') . '</span>',

View File

@@ -157,6 +157,14 @@
<span class="nav-link-text">{{ $__t('Consume') }}</span> <span class="nav-link-text">{{ $__t('Consume') }}</span>
</a> </a>
</li> </li>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
<li class="nav-item" data-toggle="tooltip" data-placement="right" title="{{ $__t('Transfer') }}" data-nav-for-page="transfer">
<a class="nav-link discrete-link" href="{{ $U('/transfer') }}">
<i class="fas fa-exchange-alt"></i>
<span class="nav-link-text">{{ $__t('Transfer') }}</span>
</a>
</li>
@endif
<li class="nav-item" data-toggle="tooltip" data-placement="right" title="{{ $__t('Inventory') }}" data-nav-for-page="inventory"> <li class="nav-item" data-toggle="tooltip" data-placement="right" title="{{ $__t('Inventory') }}" data-nav-for-page="inventory">
<a class="nav-link discrete-link" href="{{ $U('/inventory') }}"> <a class="nav-link discrete-link" href="{{ $U('/inventory') }}">
<i class="fas fa-list"></i> <i class="fas fa-list"></i>

View File

@@ -55,7 +55,7 @@
'id' => 'price', 'id' => 'price',
'label' => 'Price', 'label' => 'Price',
'min' => 0, 'min' => 0,
'step' => 0.0001, 'step' => 0.01,
'value' => '', 'value' => '',
'hint' => $__t('in %s and based on the purchase quantity unit', GROCY_CURRENCY), 'hint' => $__t('in %s and based on the purchase quantity unit', GROCY_CURRENCY),
'invalidFeedback' => $__t('The price cannot be lower than %s', '0'), 'invalidFeedback' => $__t('The price cannot be lower than %s', '0'),

View File

@@ -80,7 +80,7 @@
'id' => 'price_factor', 'id' => 'price_factor',
'label' => 'Price factor', 'label' => 'Price factor',
'min' => 0, 'min' => 0,
'step' => 0.0001, 'step' => 0.01,
'value' => '', 'value' => '',
'hint' => $__t('The resulting price of this ingredient will be multiplied by this factor'), 'hint' => $__t('The resulting price of this ingredient will be multiplied by this factor'),
'invalidFeedback' => $__t('This cannot be lower than %s', '0'), 'invalidFeedback' => $__t('This cannot be lower than %s', '0'),

197
views/stockdetail.blade.php Normal file
View File

@@ -0,0 +1,197 @@
@extends('layout.default')
@section('title', $__t('Stock entry'))
@section('activeNav', 'stockdetail')
@section('viewJsName', 'stockdetail')
@push('pageScripts')
<script src="{{ $U('/node_modules/jquery-ui-dist/jquery-ui.min.js?v=', true) }}{{ $version }}"></script>
<script src="{{ $U('/viewjs/purchase.js?v=', true) }}{{ $version }}"></script>
@endpush
@push('pageStyles')
<style>
.product-name-cell[data-product-has-picture='true'] {
cursor: pointer;
}
</style>
@endpush
@section('content')
<div class="row">
<div class="col">
<h1>@yield('title')</h1>
</div>
<div class="col">
@include('components.productpicker', array('products' => $products,'disallowAddProductWorkflows' => true))
</div>
</div>
<div class="row">
<div class="col">
<table id="stock-detail-table" class="table table-sm table-striped dt-responsive">
<thead>
<tr>
<th class="border-right"></th>
<th class="d-none">product_id</th> <!-- This must be in the first column for searching -->
<th>{{ $__t('Product') }}</th>
<th>{{ $__t('Amount') }}</th>
<th>{{ $__t('Best before date') }}</th>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)<th>{{ $__t('Location') }}</th>@endif
@if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)<th>{{ $__t('Price') }}</th>@endif
<th>{{ $__t('Purchased date') }}</th>
@include('components.userfields_thead', array(
'userfields' => $userfields
))
</tr>
</thead>
<tbody class="d-none">
@foreach($currentStockDetail as $currentStockEntry)
<tr id="stock-{{ $currentStockEntry->id }}-row" class="@if(GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING && $currentStockEntry->best_before_date < date('Y-m-d 23:59:59', strtotime('-1 days')) && $currentStockEntry->amount > 0) table-danger @elseif(GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING && $currentStockEntry->best_before_date < date('Y-m-d 23:59:59', strtotime("+$nextXDays days")) && $currentStockEntry->amount > 0) table-warning @elseif (FindObjectInArrayByPropertyValue($missingProducts, 'id', $currentStockEntry->product_id) !== null) table-info @endif">
<td class="fit-content border-right">
<a class="btn btn-success btn-sm stock-consume-button @if($currentStockEntry->amount < 1) disabled @endif" href="#" data-toggle="tooltip" data-placement="left" title="{{ $__t('Consume %1$s of %2$s', '1 ' . FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name) }}"
data-product-id="{{ $currentStockEntry->product_id }}"
data-stock-id="{{ $currentStockEntry->stock_id }}"
data-stockrow-id="{{ $currentStockEntry->id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}"
data-product-qu-name="{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name }}"
data-consume-amount="1">
<i class="fas fa-utensils"></i> 1
</a>
<a id="stock-{{ $currentStockEntry->id }}-consume-all-button" class="btn btn-danger btn-sm stock-consume-button @if($currentStockEntry->amount == 0) disabled @endif" href="#" data-toggle="tooltip" data-placement="right" title="{{ $__t('Consume all %s for this stock entry', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name) }}"
data-product-id="{{ $currentStockEntry->product_id }}"
data-stock-id="{{ $currentStockEntry->stock_id }}"
data-stockrow-id="{{ $currentStockEntry->id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}"
data-product-qu-name="{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name }}"
data-consume-amount="{{ $currentStockEntry->amount }}">
<i class="fas fa-utensils"></i> {{ $__t('All') }}
</a>
@if(GROCY_FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING)
<a class="btn btn-success btn-sm product-open-button @if($currentStockEntry->amount < 1 || $currentStockEntry->amount == $currentStockEntry->amount_opened) disabled @endif" href="#" data-toggle="tooltip" data-placement="left" title="{{ $__t('Mark %1$s of %2$s as open', '1 ' . FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name) }}"
data-product-id="{{ $currentStockEntry->product_id }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}"
data-product-qu-name="{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name }}">
<i class="fas fa-box-open"></i> 1
</a>
@endif
<div class="dropdown d-inline-block">
<button class="btn btn-sm btn-light text-secondary" type="button" data-toggle="dropdown">
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="dropdown-menu">
<a class="dropdown-item product-add-to-shopping-list-button" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-shopping-cart"></i> {{ $__t('Add to shopping list') }}
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item product-purchase-button" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-shopping-cart"></i> {{ $__t('Purchase') }}
</a>
<a class="dropdown-item product-consume-custom-amount-button @if($currentStockEntry->amount < 1) disabled @endif" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-stock-id="{{ $currentStockEntry->stock_id }}">
<i class="fas fa-utensils"></i> {{ $__t('Consume') }}
</a>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
<a class="dropdown-item product-transfer-button @if($currentStockEntry->amount < 1) disabled @endif" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-stock-id="{{ $currentStockEntry->stock_id }}">
<i class="fas fa-exchange-alt"></i> {{ $__t('Transfer') }}
</a>
@endif
<a class="dropdown-item product-inventory-button" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-list"></i> {{ $__t('Inventory') }}
</a>
<a class="dropdown-item product-stockedit-button" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}"
data-location-id="{{ $currentStockEntry->location_id }}"
data-id="{{ $currentStockEntry->id }}">
<i class="fas fa-boxes"></i> {{ $__t('Stock edit') }}
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item product-name-cell" data-product-id="{{ $currentStockEntry->product_id }}" type="button" href="#">
<i class="fas fa-info"></i> {{ $__t('Show product details') }}
</a>
<a class="dropdown-item" type="button" href="{{ $U('/stockjournal?product=') }}{{ $currentStockEntry->product_id }}">
<i class="fas fa-file-alt"></i> {{ $__t('Stock journal for this product') }}
</a>
<a class="dropdown-item" type="button" href="{{ $U('/product/') }}{{ $currentStockEntry->product_id . '?returnto=%2Fstockdetail' }}">
<i class="fas fa-edit"></i> {{ $__t('Edit product') }}
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item product-consume-button product-consume-button-spoiled @if($currentStockEntry->amount < 1) disabled @endif" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}"
data-product-qu-name="{{ FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name }}"
data-consume-amount="1">
<i class="fas fa-utensils"></i> {{ $__t('Consume %1$s of %2$s as spoiled', '1 ' . FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name) }}
</a>
@if(GROCY_FEATURE_FLAG_RECIPES)
<a class="dropdown-item" type="button" href="{{ $U('/recipes?search=') }}{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}">
<i class="fas fa-cocktail"></i> {{ $__t('Search for recipes containing this product') }}
</a>
@endif
</div>
</div>
</td>
<td class="d-none" data-product-id="{{ $currentStockEntry->product_id }}">
{{ $currentStockEntry->product_id }}
</td>
<td class="product-name-cell cursor-link" data-product-id="{{ $currentStockEntry->product_id }}">
{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}
</td>
<td>
<span id="stock-{{ $currentStockEntry->id }}-amount" class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->amount }}</span> <span id="product-{{ $currentStockEntry->product_id }}-qu-name">{{ $__n($currentStockEntry->amount, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name_plural) }}</span>
<span id="stock-{{ $currentStockEntry->id }}-opened-amount" class="small font-italic">@if($currentStockEntry->amount_opened > 0){{ $__t('%s opened', $currentStockEntry->amount_opened) }}@endif</span>
@if($currentStockEntry->is_aggregated_amount == 1)
<span class="pl-1 text-secondary">
<i class="fas fa-custom-sigma-sign"></i> <span id="product-{{ $currentStockEntry->product_id }}-amount-aggregated" class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->amount_aggregated }}</span> {{ $__n($currentStockEntry->amount_aggregated, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name_plural) }}
@if($currentStockEntry->amount_opened_aggregated > 0)<span id="product-{{ $currentStockEntry->product_id }}-opened-amount-aggregated" class="small font-italic">{{ $__t('%s opened', $currentStockEntry->amount_opened_aggregated) }}</span>@endif
</span>
@endif
</td>
<td>
<span id="stock-{{ $currentStockEntry->id }}-best-before-date">{{ $currentStockEntry->best_before_date }}</span>
<time id="stock-{{ $currentStockEntry->id }}-best-before-date-timeago" class="timeago timeago-contextual" datetime="{{ $currentStockEntry->best_before_date }} 23:59:59"></time>
</td>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
<td id="stock-{{ $currentStockEntry->id }}-location" class="location-name-cell cursor-link" data-location-id="{{ $currentStockEntry->location_id }}">
{{ FindObjectInArrayByPropertyValue($locations, 'id', $currentStockEntry->location_id)->name }}
</td>
@endif
@if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)
<td id="stock-{{ $currentStockEntry->id }}-price" class="price-name-cell cursor-link" data-price-id="{{ $currentStockEntry->price }}">
{{ $currentStockEntry->price }}
</td>
@endif
<td>
<span id="stock-{{ $currentStockEntry->id }}-purchased-date">{{ $currentStockEntry->purchased_date }}</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="modal fade" id="stockdetail-productcard-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content text-center">
<div class="modal-body">
@include('components.productcard')
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ $__t('Close') }}</button>
</div>
</div>
</div>
</div>
@stop

81
views/stockedit.blade.php Normal file
View File

@@ -0,0 +1,81 @@
@extends('layout.default')
@section('title', $__t('Stock edit'))
@section('activeNav', 'stockedit')
@section('viewJsName', 'stockedit')
@section('content')
<div class="row">
<div class="col-xs-12 col-md-6 col-xl-4 pb-3">
<h1>@yield('title')</h1>
<form id="stockedit-form" novalidate>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
@include('components.locationpicker', array(
'locations' => $locations
))
@else
<input type="hidden" name="location_id" id="location_id" value="1">
@endif
@include('components.numberpicker', array(
'id' => 'amount',
'label' => 'Amount',
'hintId' => 'amount_qu_unit',
'invalidFeedback' => $__t('The amount cannot be lower than %s', '0'),
'additionalAttributes' => 'data-not-equal="-1"',
'additionalHtmlElements' => '<div id="stockedit-change-info" class="form-text text-muted small d-none"></div>',
'additionalHtmlContextHelp' => '<div id="tare-weight-handling-info" class="text-small text-info font-italic d-none">' . $__t('Tare weight handling enabled - please weigh the whole container, the amount to be posted will be automatically calculcated') . '</div>'
))
@php
$additionalGroupCssClasses = '';
if (!GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING)
{
$additionalGroupCssClasses = 'd-none';
}
@endphp
@include('components.datetimepicker', array(
'id' => 'best_before_date',
'label' => 'Best before',
'format' => 'YYYY-MM-DD',
'initWithNow' => false,
'limitEndToNow' => false,
'limitStartToNow' => false,
'invalidFeedback' => $__t('A best before date is required'),
'nextInputSelector' => '#best_before_date',
'additionalGroupCssClasses' => 'date-only-datetimepicker',
'shortcutValue' => '2999-12-31',
'shortcutLabel' => 'Never expires',
'earlierThanInfoLimit' => date('Y-m-d'),
'earlierThanInfoText' => $__t('The given date is earlier than today, are you sure?'),
'additionalGroupCssClasses' => $additionalGroupCssClasses
))
@php $additionalGroupCssClasses = ''; @endphp
@if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)
@include('components.numberpicker', array(
'id' => 'price',
'label' => 'Price',
'min' => 0,
'step' => 0.01,
'value' => '',
'hint' => $__t('in %s per purchase quantity unit', GROCY_CURRENCY),
'invalidFeedback' => $__t('The price cannot be lower than %s', '0'),
'isRequired' => false
))
@else
<input type="hidden" name="price" id="price" value="0">
@endif
<button id="save-stockedit-button" class="btn btn-success">{{ $__t('OK') }}</button>
</form>
</div>
<div class="col-xs-12 col-md-6 col-xl-4 hide-when-embedded">
@include('components.productcard')
</div>
</div>
@stop

View File

@@ -37,6 +37,7 @@
<th>{{ $__t('Amount') }}</th> <th>{{ $__t('Amount') }}</th>
<th>{{ $__t('Booking time') }}</th> <th>{{ $__t('Booking time') }}</th>
<th>{{ $__t('Booking type') }}</th> <th>{{ $__t('Booking type') }}</th>
<th class="@if(!GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) d-none @endif">{{ $__t('Location') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="d-none"> <tbody class="d-none">
@@ -65,6 +66,9 @@
<td> <td>
{{ $__t($stockLogEntry->transaction_type) }} {{ $__t($stockLogEntry->transaction_type) }}
</td> </td>
<td class="@if(!GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) d-none @endif">
{{ FindObjectInArrayByPropertyValue($locations, 'id', $stockLogEntry->location_id)->name }}
</td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>

View File

@@ -140,6 +140,12 @@
data-product-id="{{ $currentStockEntry->product_id }}"> data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-utensils"></i> {{ $__t('Consume') }} <i class="fas fa-utensils"></i> {{ $__t('Consume') }}
</a> </a>
@if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING)
<a class="dropdown-item product-transfer-button @if($currentStockEntry->amount < 1) disabled @endif" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-exchange-alt"></i> {{ $__t('Transfer') }}
</a>
@endif
<a class="dropdown-item product-inventory-button" type="button" href="#" <a class="dropdown-item product-inventory-button" type="button" href="#"
data-product-id="{{ $currentStockEntry->product_id }}"> data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-list"></i> {{ $__t('Inventory') }} <i class="fas fa-list"></i> {{ $__t('Inventory') }}
@@ -148,6 +154,10 @@
<a class="dropdown-item product-name-cell" data-product-id="{{ $currentStockEntry->product_id }}" type="button" href="#"> <a class="dropdown-item product-name-cell" data-product-id="{{ $currentStockEntry->product_id }}" type="button" href="#">
<i class="fas fa-info"></i> {{ $__t('Show product details') }} <i class="fas fa-info"></i> {{ $__t('Show product details') }}
</a> </a>
<a class="dropdown-item" type="button" href="{{ $U('/stockdetail?product=') }}{{ $currentStockEntry->product_id }}"
data-product-id="{{ $currentStockEntry->product_id }}">
<i class="fas fa-boxes"></i> {{ $__t('Show stock entries') }}
</a>
<a class="dropdown-item" type="button" href="{{ $U('/stockjournal?product=') }}{{ $currentStockEntry->product_id }}"> <a class="dropdown-item" type="button" href="{{ $U('/stockjournal?product=') }}{{ $currentStockEntry->product_id }}">
<i class="fas fa-file-alt"></i> {{ $__t('Stock journal for this product') }} <i class="fas fa-file-alt"></i> {{ $__t('Stock journal for this product') }}
</a> </a>

84
views/transfer.blade.php Normal file
View File

@@ -0,0 +1,84 @@
@extends('layout.default')
@section('title', $__t('Transfer'))
@section('activeNav', 'transfer')
@section('viewJsName', 'transfer')
@section('content')
<div class="row">
<div class="col-xs-12 col-md-6 col-xl-4 pb-3">
<h1>@yield('title')</h1>
<form id="transfer-form" novalidate>
@include('components.productpicker', array(
'products' => $products,
'nextInputSelector' => '#location_id_from',
'disallowAddProductWorkflows' => true
))
@php /*@include('components.locationpicker', array(
'id' => 'location_from',
'locations' => $locations,
'isRequired' => true,
'label' => 'Transfer From Location'
))*/ @endphp
<div class="form-group">
<label for="location_id_from">{{ $__t('From location') }}</label>
<select required class="form-control location-combobox" id="location_id_from" name="location_id_from">
<option></option>
@foreach($locations as $location)
<option value="{{ $location->id }}">{{ $location->name }}</option>
@endforeach
</select>
<div class="invalid-feedback">{{ $__t('A location is required') }}</div>
</div>
@include('components.numberpicker', array(
'id' => 'amount',
'label' => 'Amount',
'hintId' => 'amount_qu_unit',
'min' => 1,
'value' => 1,
'invalidFeedback' => $__t('The amount cannot be lower than %s', '1'),
'additionalHtmlContextHelp' => '<div id="tare-weight-handling-info" class="text-info font-italic d-none">' . $__t('Tare weight handling enabled - please weigh the whole container, the amount to be posted will be automatically calculcated') . '</div>'
))
<div class="form-group">
<label for="use_specific_stock_entry">
<input type="checkbox" id="use_specific_stock_entry" name="use_specific_stock_entry"> {{ $__t('Use a specific stock item') }}
<span class="small text-muted">{{ $__t('The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"') }}</span>
</label>
<select disabled class="form-control" id="specific_stock_entry" name="specific_stock_entry">
<option></option>
</select>
</div>
@php /*@include('components.locationpicker', array(
'locations' => $locations,
'isRequired' => true,
'label' => 'Transfer to Location'
))*/ @endphp
<div class="form-group">
<label for="location_id_to">{{ $__t('To location') }}</label>
<select required class="form-control location-combobox" id="location_id_to" name="location_id_to">
<option></option>
@foreach($locations as $location)
<option value="{{ $location->id }}">{{ $location->name }}</option>
@endforeach
</select>
<div class="invalid-feedback">{{ $__t('A location is required') }}</div>
</div>
<button id="save-transfer-button" class="btn btn-success">{{ $__t('OK') }}</button>
</form>
</div>
<div class="col-xs-12 col-md-6 col-xl-4 hide-when-embedded">
@include('components.productcard')
</div>
</div>
@stop