Make it possible to mark a product as opened (closes #86)

This commit is contained in:
Bernd Bestel 2018-11-17 19:39:37 +01:00
parent 816ca6460f
commit 10ea9c44fd
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
12 changed files with 356 additions and 8 deletions

View File

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

View File

@ -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)",

View File

@ -3,5 +3,6 @@
return array(
'purchase' => 'Purchase',
'consume' => 'Consume',
'inventory-correction' => 'Inventory correction'
'inventory-correction' => 'Inventory correction',
'product-opened' => 'Product opened'
);

View File

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

17
migrations/0046.sql Normal file
View File

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

View File

@ -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("<option></option>");
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) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockBooking(' + result.booking_id + ')"><i class="fas fa-undo"></i> ' + L("Undo") + '</a>');
$('#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("<option></option>");
@ -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($("<option>", {
value: stockEntry.stock_id,
text: L("Expires on #1; bought on #2", moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD"))
text: L("Expires on #1; Bought on #2", moment(stockEntry.best_before_date).format("YYYY-MM-DD"), moment(stockEntry.purchased_date).format("YYYY-MM-DD")) + "; " + openTxt
}));
}
});

View File

@ -152,6 +152,64 @@ $(document).on('click', '.product-consume-button', function(e)
);
});
$(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();
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');
Grocy.Api.Get('stock/open-product/' + productId + '/1',
function()
{
Grocy.Api.Get('stock/get-product-details/' + productId,
function(result)
{
var productRow = $('#product-' + productId + '-row');
var expiringThreshold = moment().add("-" + $("#info-expiring-products").data("next-x-days"), "days");
var now = moment();
var nextBestBeforeDate = moment(result.next_best_before_date);
productRow.removeClass("table-warning");
productRow.removeClass("table-danger");
if (now.isAfter(nextBestBeforeDate))
{
productRow.addClass("table-danger");
}
if (expiringThreshold.isAfter(nextBestBeforeDate))
{
productRow.addClass("table-warning");
}
$('#product-' + productId + '-next-best-before-date').parent().effect('highlight', {}, 500);
$('#product-' + productId + '-next-best-before-date').fadeOut(500, function ()
{
$(this).text(result.next_best_before_date).fadeIn(500);
});
$('#product-' + productId + '-next-best-before-date-timeago').attr('datetime', result.next_best_before_date);
toastr.success(L('Marked #1 #2 of #3 as opened', 1, productQuName, productName));
RefreshContextualTimeago();
RefreshStatistics();
},
function(xhr)
{
console.error(xhr);
}
);
},
function(xhr)
{
console.error(xhr);
}
);
});
$(document).on("click", ".product-name-cell", function(e)
{
Grocy.Components.ProductCard.Refresh($(e.currentTarget).attr("data-product-id"));

View File

@ -108,6 +108,7 @@ $app->group('/api', function()
// Stock
$this->get('/stock/add-product/{productId}/{amount}', '\Grocy\Controllers\StockApiController:AddProduct');
$this->get('/stock/consume-product/{productId}/{amount}', '\Grocy\Controllers\StockApiController:ConsumeProduct');
$this->get('/stock/open-product/{productId}/{amount}', '\Grocy\Controllers\StockApiController:OpenProduct');
$this->get('/stock/inventory-product/{productId}/{newAmount}', '\Grocy\Controllers\StockApiController:InventoryProduct');
$this->get('/stock/get-product-details/{productId}', '\Grocy\Controllers\StockApiController:ProductDetails');
$this->get('/stock/get-product-price-history/{productId}', '\Grocy\Controllers\StockApiController:ProductPriceHistory');

View File

@ -7,6 +7,7 @@ class StockService extends BaseService
const TRANSACTION_TYPE_PURCHASE = 'purchase';
const TRANSACTION_TYPE_CONSUME = 'consume';
const TRANSACTION_TYPE_INVENTORY_CORRECTION = 'inventory-correction';
const TRANSACTION_TYPE_PRODUCT_OPENED = 'product-opened';
public function GetCurrentStock($includeNotInStockButMissingProducts = false)
{
@ -91,11 +92,19 @@ class StockService extends BaseService
return $returnData;
}
public function GetProductStockEntries($productId)
public function GetProductStockEntries($productId, $excludeOpened = false)
{
// In order of next use:
// First expiring first, then first in first out
return $this->Database->stock()->where('product_id', $productId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll();
if ($excludeOpened)
{
return $this->Database->stock()->where('product_id = :1 AND open = 0', $productId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll();
}
else
{
return $this->Database->stock()->where('product_id', $productId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll();
}
}
public function AddProduct(int $productId, int $amount, string $bestBeforeDate, $transactionType, $purchasedDate, $price)
@ -180,7 +189,8 @@ class StockService extends BaseService
'spoiled' => $spoiled,
'stock_id' => $stockEntry->stock_id,
'transaction_type' => $transactionType,
'price' => $stockEntry->price
'price' => $stockEntry->price,
'opened_date' => $stockEntry->opened_date
));
$logRow->save();
@ -198,7 +208,8 @@ class StockService extends BaseService
'spoiled' => $spoiled,
'stock_id' => $stockEntry->stock_id,
'transaction_type' => $transactionType,
'price' => $stockEntry->price
'price' => $stockEntry->price,
'opened_date' => $stockEntry->opened_date
));
$logRow->save();
@ -243,6 +254,91 @@ class StockService extends BaseService
return $this->Database->lastInsertId();
}
public function OpenProduct(int $productId, int $amount, $specificStockEntryId = 'default')
{
if (!$this->ProductExists($productId))
{
throw new \Exception('Product does not exist');
}
$productStockAmount = $this->Database->stock()->where('product_id', $productId)->sum('amount');
$potentialStockEntries = $this->GetProductStockEntries($productId, true);
$product = $this->Database->products($productId);
if ($amount > $productStockAmount)
{
return false;
}
if ($specificStockEntryId !== 'default')
{
$potentialStockEntries = FindAllObjectsInArrayByPropertyValue($potentialStockEntries, 'stock_id', $specificStockEntryId);
}
foreach ($potentialStockEntries as $stockEntry)
{
if ($amount == 0)
{
break;
}
$newBestBeforeDate = $stockEntry->best_before_date;
if ($product->default_best_before_days_after_open > 0)
{
$newBestBeforeDate = date("Y-m-d", (strtotime('+' . $product->default_best_before_days_after_open . ' days')));
}
if ($amount >= $stockEntry->amount) //Take the whole stock entry
{
$logRow = $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_PRODUCT_OPENED,
'price' => $stockEntry->price,
'opened_date' => date('Y-m-d')
));
$logRow->save();
$amount -= $stockEntry->amount;
$stockEntry->update(array(
'open' => 1,
'opened_date' => date('Y-m-d'),
'best_before_date' => $newBestBeforeDate
));
}
else //Stock entry amount is > than needed amount -> split the stock entry resp. update the amount
{
$logRow = $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_PRODUCT_OPENED,
'price' => $stockEntry->price,
'opened_date' => date('Y-m-d')
));
$logRow->save();
$restStockAmount = $stockEntry->amount - $amount;
$amount = 0;
$stockEntry->update(array(
'amount' => $restStockAmount,
'open' => 1,
'opened_date' => date('Y-m-d'),
'best_before_date' => $newBestBeforeDate
));
}
}
return $this->Database->lastInsertId();
}
public function AddMissingProductsToShoppingList()
{
$missingProducts = $this->GetMissingProducts();
@ -365,6 +461,21 @@ class StockService extends BaseService
'undone_timestamp' => date('Y-m-d H:i:s')
));
}
elseif ($logRow->transaction_type === self::TRANSACTION_TYPE_PRODUCT_OPENED)
{
// Remove opened flag from corresponding log entry
$stockRows = $this->Database->stock()->where('stock_id = :1 AND amount = :2', $logRow->stock_id, $logRow->amount);
$stockRows->update(array(
'open' => 0,
'opened_date' => null
));
// Update log entry
$logRow->update(array(
'undone' => 1,
'undone_timestamp' => date('Y-m-d H:i:s')
));
}
else
{
throw new \Exception('This booking cannot be undone');

View File

@ -43,6 +43,7 @@
</div>
<button id="save-consume-button" class="btn btn-success">{{ $L('OK') }}</button>
<button id="save-mark-as-open-button" class="btn btn-secondary">{{ $L('Mark as opened') }}</button>
</form>
</div>

View File

@ -73,6 +73,16 @@
'hint' => $L('For purchases this amount of days will be added to today for the best before date suggestion') . ' (' . $L('-1 means that this product never expires') . ')'
))
@php if($mode == 'edit') { $value = $product->default_best_before_days_after_open; } else { $value = 0; } @endphp
@include('components.numberpicker', array(
'id' => 'default_best_before_days_after_open',
'label' => 'Default best before days after opened',
'min' => 0,
'value' => $value,
'invalidFeedback' => $L('The amount cannot be lower than #1', '-1'),
'hint' => $L('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)')
))
<div class="form-group">
<label for="product_group_id">{{ $L('Product group') }}</label>
<select class="form-control" id="product_group_id" name="product_group_id">

View File

@ -97,6 +97,12 @@
data-consume-amount="{{ $currentStockEntry->amount }}">
<i class="fas fa-utensils"></i> {{ $L('All') }}
</a>
<a class="btn btn-success btn-sm product-open-button @if($currentStockEntry->amount == 0) disabled @endif" href="#" data-toggle="tooltip" data-placement="left" title="{{ $L('Mark #3 #1 of #2 as open', FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name, 1) }}"
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>
<a class="btn btn-info btn-sm" href="{{ $U('/stockjournal?product=') }}{{ $currentStockEntry->product_id }}">
<i class="fas fa-file-alt"></i>
</a>