diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php
index 4578f64d..07b91151 100644
--- a/controllers/StockApiController.php
+++ b/controllers/StockApiController.php
@@ -119,6 +119,25 @@ class StockApiController extends BaseApiController
}
}
+ public function OpenProduct(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
+ {
+ $specificStockEntryId = "default";
+ if (isset($request->getQueryParams()['stock_entry_id']) && !empty($request->getQueryParams()['stock_entry_id']))
+ {
+ $specificStockEntryId = $request->getQueryParams()['stock_entry_id'];
+ }
+
+ try
+ {
+ $bookingId = $this->StockService->OpenProduct($args['productId'], $args['amount'], $specificStockEntryId);
+ return $this->ApiResponse(array('booking_id' => $bookingId));
+ }
+ catch (\Exception $ex)
+ {
+ return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage());
+ }
+ }
+
public function CurrentStock(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
return $this->ApiResponse($this->StockService->GetCurrentStock());
diff --git a/grocy.openapi.json b/grocy.openapi.json
index 2438954e..85dc302d 100644
--- a/grocy.openapi.json
+++ b/grocy.openapi.json
@@ -1003,6 +1003,66 @@
}
}
},
+ "/stock/open-product/{productId}/{amount}": {
+ "get": {
+ "description": "Marks the the given amount of the given product as opened",
+ "tags": [
+ "Stock"
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "productId",
+ "required": true,
+ "description": "A valid product id",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "path",
+ "name": "amount",
+ "required": false,
+ "description": "The amount to remove",
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ {
+ "in": "query",
+ "name": "stock_entry_id",
+ "required": false,
+ "description": "A specific stock entry id to open, if used, the amount has to be 1",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A VoidApiActionResponse object",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/VoidApiActionResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "A VoidApiActionResponse object (possible errors are: Not existing product)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/stock/inventory-product/{productId}/{newAmount}": {
"get": {
"description": "Inventories the the given product (adds/removes based on the given new current amount)",
diff --git a/localization/en/stock_transaction_types.php b/localization/en/stock_transaction_types.php
index 22a57178..24d87eff 100644
--- a/localization/en/stock_transaction_types.php
+++ b/localization/en/stock_transaction_types.php
@@ -3,5 +3,6 @@
return array(
'purchase' => 'Purchase',
'consume' => 'Consume',
- 'inventory-correction' => 'Inventory correction'
+ 'inventory-correction' => 'Inventory correction',
+ 'product-opened' => 'Product opened'
);
diff --git a/localization/en/strings.php b/localization/en/strings.php
index 04895cba..c27df1fd 100644
--- a/localization/en/strings.php
+++ b/localization/en/strings.php
@@ -308,5 +308,13 @@ return array(
'Adding shopping list item #1 of #2' => 'Adding shopping list item #1 of #2',
'Use a specific stock item' => 'Use a specific stock item',
'Expires on #1; bought on #2' => 'Expires on #1; bought on #2',
- 'The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"' => 'The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"'
+ 'The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"' => 'The first item in this list would be picked by the default rule which is "First expiring first, then first in first out"',
+ 'Mark #3 #1 of #2 as open' => 'Mark #3 #1 of #2 as open',
+ 'When a product was marked as opened, the best before date will be replaced by today + this amount of days (a value of 0 disables this)' => 'When a product was marked as opened, the best before date will be replaced by today + this amount of days (a value of 0 disables this)',
+ 'Default best before days after opened' => 'Default best before days after opened',
+ 'Marked #1 #2 of #3 as opened' => 'Marked #1 #2 of #3 as opened',
+ 'Mark as opened' => 'Mark as opened',
+ 'Expires on #1; Bought on #2' => 'Expires on #1; Bought on #2',
+ 'Not opened' => 'Not opened',
+ 'Opened' => 'Opened'
);
diff --git a/migrations/0046.sql b/migrations/0046.sql
new file mode 100644
index 00000000..262b1d6b
--- /dev/null
+++ b/migrations/0046.sql
@@ -0,0 +1,17 @@
+ALTER TABLE stock
+ADD opened_date DATETIME;
+
+ALTER TABLE stock_log
+ADD opened_date DATETIME;
+
+ALTER TABLE stock
+ADD open TINYINT NOT NULL DEFAULT 0 CHECK(open IN (0, 1));
+
+UPDATE stock
+SET open = 0;
+
+ALTER TABLE products
+ADD default_best_before_days_after_open INTEGER NOT NULL DEFAULT 0;
+
+UPDATE products
+SET default_best_before_days_after_open = 0;
diff --git a/public/viewjs/consume.js b/public/viewjs/consume.js
index 1208b85a..3b57b69e 100644
--- a/public/viewjs/consume.js
+++ b/public/viewjs/consume.js
@@ -23,7 +23,7 @@
}
Grocy.Api.Get('stock/get-product-details/' + jsonForm.product_id,
- function (productDetails)
+ function(productDetails)
{
Grocy.Api.Get(apiUrl,
function(result)
@@ -54,6 +54,56 @@
);
});
+$('#save-mark-as-open-button').on('click', function(e)
+{
+ e.preventDefault();
+
+ var jsonForm = $('#consume-form').serializeJSON();
+
+ if ($("#use_specific_stock_entry").is(":checked"))
+ {
+ jsonForm.amount = 1;
+ }
+
+ var apiUrl = 'stock/open-product/' + jsonForm.product_id + '/' + jsonForm.amount;
+
+ if ($("#use_specific_stock_entry").is(":checked"))
+ {
+ apiUrl += "&stock_entry_id=" + jsonForm.specific_stock_entry;
+ }
+
+ Grocy.Api.Get('stock/get-product-details/' + jsonForm.product_id,
+ function(productDetails)
+ {
+ Grocy.Api.Get(apiUrl,
+ function(result)
+ {
+ $("#specific_stock_entry").find("option").remove().end().append("");
+ if ($("#use_specific_stock_entry").is(":checked"))
+ {
+ $("#use_specific_stock_entry").click();
+ }
+
+ toastr.success(L('Marked #1 #2 of #3 as opened', jsonForm.amount, Pluralize(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + ' ' + L("Undo") + '');
+
+ $('#amount').val(1);
+ Grocy.Components.ProductPicker.SetValue('');
+ Grocy.Components.ProductPicker.GetInputElement().focus();
+ Grocy.FrontendHelpers.ValidateForm('consume-form');
+ },
+ function(xhr)
+ {
+ console.error(xhr);
+ }
+ );
+ },
+ function(xhr)
+ {
+ console.error(xhr);
+ }
+ );
+});
+
Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
{
$("#specific_stock_entry").find("option").remove().end().append("");
@@ -99,11 +149,17 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
{
stockEntries.forEach(stockEntry =>
{
+ var openTxt = L("Not opened");
+ if (stockEntry.open == 1)
+ {
+ openTxt = L("Opened");
+ }
+
for (i = 0; i < stockEntry.amount; i++)
{
$("#specific_stock_entry").append($("