mirror of
https://github.com/grocy/grocy.git
synced 2025-04-29 01:32:38 +00:00
Make it possible to mark a product as opened (closes #86)
This commit is contained in:
parent
816ca6460f
commit
10ea9c44fd
@ -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());
|
||||
|
@ -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)",
|
||||
|
@ -3,5 +3,6 @@
|
||||
return array(
|
||||
'purchase' => 'Purchase',
|
||||
'consume' => 'Consume',
|
||||
'inventory-correction' => 'Inventory correction'
|
||||
'inventory-correction' => 'Inventory correction',
|
||||
'product-opened' => 'Product opened'
|
||||
);
|
||||
|
@ -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
17
migrations/0046.sql
Normal 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;
|
@ -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
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
@ -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"));
|
||||
|
@ -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');
|
||||
|
@ -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,12 +92,20 @@ 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
|
||||
|
||||
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');
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user