Add support for "Move on Open" (#1863)

* Add functionality to move a product when it is opened

* Update the API to support this (and some other new fields)

* Remove console, update move on open when either the default or the consume location change

* Fix conflict from fridge

* Ignore .DS_STORE from macOS

* Fix the migration conflict

* Fix the default location not appending properly

* Revert changes no longer needed

* Fix the checkbox disable logic, and call the function on page load

* Simplify the transfer to use the existing function (which also adds logs)

* Only move it if it's moving

* Code formatting / naming

* Clarify help text (it's not always about one unit, but about the corresponding amount opened)

* Handle splitted stock entries + optimized/unified product property checks

* Added UI feedback on auto moving

Co-authored-by: Bernd Bestel <bernd@berrnd.de>
This commit is contained in:
Rosemary Orchard 2022-04-18 17:25:08 +01:00 committed by GitHub
parent 0152f1c69d
commit 5e30e89737
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 117 additions and 7 deletions

3
.gitignore vendored
View File

@ -1,4 +1,5 @@
/public/node_modules /public/node_modules
/vendor /vendor
/.release /.release
embedded.txt embedded.txt
.DS_Store

View File

@ -4481,6 +4481,15 @@
"userfields": { "userfields": {
"type": "object", "type": "object",
"description": "Key/value pairs of userfields" "description": "Key/value pairs of userfields"
},
"should_not_be_frozen": {
"type": "integer"
},
"default_consume_location_id": {
"type": "integer"
},
"move_on_open": {
"type": "integer"
} }
}, },
"example": { "example": {
@ -4502,7 +4511,10 @@
"tare_weight": "0.0", "tare_weight": "0.0",
"not_check_stock_fulfillment_for_recipes": "0", "not_check_stock_fulfillment_for_recipes": "0",
"shopping_location_id": null, "shopping_location_id": null,
"userfields": null "userfields": null,
"should_not_be_frozen": "1",
"default_consume_location_id": "5",
"move_on_open": "1"
} }
}, },
"QuantityUnit": { "QuantityUnit": {
@ -4785,6 +4797,9 @@
"has_childs": { "has_childs": {
"type": "boolean", "type": "boolean",
"description": "True when the product is a parent product of others" "description": "True when the product is a parent product of others"
},
"default_location": {
"$ref": "#/components/schemas/Location"
} }
}, },
"example": { "example": {
@ -4850,7 +4865,8 @@
"row_created_timestamp": "2019-05-02 20:12:25" "row_created_timestamp": "2019-05-02 20:12:25"
}, },
"average_shelf_life_days": -1, "average_shelf_life_days": -1,
"spoil_rate_percent": 0 "spoil_rate_percent": 0,
"default_consume_location": null
} }
}, },
"ProductPriceHistory": { "ProductPriceHistory": {

View File

@ -2326,3 +2326,12 @@ msgstr ""
msgid "Stock entries at this location will be consumed first" msgid "Stock entries at this location will be consumed first"
msgstr "" msgstr ""
msgid "Move on open"
msgstr ""
msgid "When enabled, on marking this product as opened, the corresponding amount will be moved to the default consume location"
msgstr ""
msgid "Moved to %1$s"
msgstr ""

2
migrations/0190.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE products
ADD move_on_open TINYINT NOT NULL DEFAULT 0 CHECK(move_on_open IN (0, 1));

View File

@ -189,6 +189,11 @@ $('#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', parseFloat(jsonForm.amount).toLocaleString({ minimumFractionDigits: 0, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_amounts }) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural, true), productDetails.product.name) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + result[0].transaction_id + '\')"><i class="fa-solid fa-undo"></i> ' + __t("Undo") + '</a>'); toastr.success(__t('Marked %1$s of %2$s as opened', parseFloat(jsonForm.amount).toLocaleString({ minimumFractionDigits: 0, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_amounts }) + " " + __n(jsonForm.amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural, true), productDetails.product.name) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + result[0].transaction_id + '\')"><i class="fa-solid fa-undo"></i> ' + __t("Undo") + '</a>');
if (productDetails.product.move_on_open == 1)
{
toastr.info('<span>' + __t("Moved to %1$s", productDetails.default_consume_location.name) + "</span> <i class='fa-solid fa-exchange-alt'></i>");
}
if (BoolVal(Grocy.UserSettings.stock_default_consume_amount_use_quick_consume_amount)) if (BoolVal(Grocy.UserSettings.stock_default_consume_amount_use_quick_consume_amount))
{ {
$('#display_amount').val(productDetails.product.quick_consume_amount); $('#display_amount').val(productDetails.product.quick_consume_amount);

View File

@ -239,8 +239,30 @@ $('#product-form input').keyup(function(event)
$('#location_id').change(function(event) $('#location_id').change(function(event)
{ {
Grocy.FrontendHelpers.ValidateForm('product-form'); Grocy.FrontendHelpers.ValidateForm('product-form');
UpdateMoveOnOpen();
}); });
$('#default_consume_location_id').change(function(event)
{
UpdateMoveOnOpen();
});
function UpdateMoveOnOpen()
{
var defaultLocation = $("#location_id :selected").val();
var consumeLocationLocation = $("#default_consume_location_id :selected").val();
if (!consumeLocationLocation || defaultLocation === consumeLocationLocation)
{
document.getElementById("move_on_open").checked = false;
$("#move_on_open").attr("disabled", true);
}
else
{
$("#move_on_open").attr("disabled", false);
}
}
$('#product-form input').keydown(function(event) $('#product-form input').keydown(function(event)
{ {
if (event.keyCode === 13) // Enter if (event.keyCode === 13) // Enter
@ -556,6 +578,7 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
} }
}); });
UpdateMoveOnOpen();
Grocy.FrontendHelpers.ValidateForm("product-form"); Grocy.FrontendHelpers.ValidateForm("product-form");
Grocy.Components.ProductPicker.GetPicker().trigger("change"); Grocy.Components.ProductPicker.GetPicker().trigger("change");

View File

@ -126,10 +126,26 @@ $(document).on('click', '.product-open-button', function(e)
Grocy.Api.Post('stock/products/' + productId + '/open', { 'amount': 1, 'stock_entry_id': specificStockEntryId }, Grocy.Api.Post('stock/products/' + productId + '/open', { 'amount': 1, 'stock_entry_id': specificStockEntryId },
function(bookingResponse) function(bookingResponse)
{ {
button.addClass("disabled"); Grocy.Api.Get('stock/products/' + productId,
Grocy.FrontendHelpers.EndUiBusy(); function(result)
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="UndoStockBookingEntry(' + bookingResponse[0].id + ',' + stockRowId + ')"><i class="fa-solid fa-undo"></i> ' + __t("Undo") + '</a>'); {
RefreshStockEntryRow(stockRowId); 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="UndoStockBookingEntry(' + bookingResponse[0].id + ',' + stockRowId + ')"><i class="fa-solid fa-undo"></i> ' + __t("Undo") + '</a>');
if (result.product.move_on_open == 1)
{
toastr.info('<span>' + __t("Moved to %1$s", result.default_consume_location.name) + "</span> <i class='fa-solid fa-exchange-alt'></i>");
}
RefreshStockEntryRow(stockRowId);
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy();
console.error(xhr);
}
);
}, },
function(xhr) function(xhr)
{ {

View File

@ -208,6 +208,12 @@ $(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', parseFloat(amount).toLocaleString({ minimumFractionDigits: 0, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_amounts }) + " " + productQuName, productName) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + bookingResponse[0].transaction_id + '\')"><i class="fa-solid fa-undo"></i> ' + __t("Undo") + '</a>'); toastr.success(__t('Marked %1$s of %2$s as opened', parseFloat(amount).toLocaleString({ minimumFractionDigits: 0, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_amounts }) + " " + productQuName, productName) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + bookingResponse[0].transaction_id + '\')"><i class="fa-solid fa-undo"></i> ' + __t("Undo") + '</a>');
if (result.product.move_on_open == 1)
{
toastr.info('<span>' + __t("Moved to %1$s", result.default_consume_location.name) + "</span> <i class='fa-solid fa-exchange-alt'></i>");
}
RefreshStatistics(); RefreshStatistics();
RefreshProductRow(productId); RefreshProductRow(productId);
}, },

View File

@ -734,6 +734,12 @@ class StockService extends BaseService
} }
$spoilRate = ($consumeCountSpoiled * 100.0) / $consumeCount; $spoilRate = ($consumeCountSpoiled * 100.0) / $consumeCount;
$defaultConsumeLocation = null;
if (!empty($product->default_consume_location_id))
{
$defaultConsumeLocation = $this->getDatabase()->locations($product->default_consume_location_id);
}
return [ return [
'product' => $product, 'product' => $product,
'product_barcodes' => $productBarcodes, 'product_barcodes' => $productBarcodes,
@ -758,6 +764,7 @@ class StockService extends BaseService
'spoil_rate_percent' => $spoilRate, 'spoil_rate_percent' => $spoilRate,
'is_aggregated_amount' => $stockCurrentRow->is_aggregated_amount, 'is_aggregated_amount' => $stockCurrentRow->is_aggregated_amount,
'has_childs' => $this->getDatabase()->products()->where('parent_product_id = :1', $product->id)->count() !== 0, 'has_childs' => $this->getDatabase()->products()->where('parent_product_id = :1', $product->id)->count() !== 0,
'default_consume_location' => $defaultConsumeLocation
]; ];
} }
@ -1057,6 +1064,15 @@ class StockService extends BaseService
$amount = 0; $amount = 0;
} }
if ($product->move_on_open == 1)
{
$locationIdTo = $product->default_consume_location_id;
if (!empty($locationIdTo) && $locationIdTo != $stockEntry->location_id)
{
$this->TransferProduct($stockEntry->product_id, $stockEntry->amount, $stockEntry->location_id, $locationIdTo, $stockEntry->stock_id, $transactionId);
}
}
} }
return $transactionId; return $transactionId;

View File

@ -146,6 +146,22 @@
$location->id == $product->default_consume_location_id) selected="selected" @endif value="{{ $location->id }}">{{ $location->name }}</option> $location->id == $product->default_consume_location_id) selected="selected" @endif value="{{ $location->id }}">{{ $location->name }}</option>
@endforeach @endforeach
</select> </select>
@if(GROCY_FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING)
<div class="custom-control custom-checkbox">
<input @if($mode=='edit'
&&
$product->move_on_open == 1) checked @endif class="form-check-input custom-control-input" type="checkbox" id="move_on_open" name="move_on_open" value="1">
<label class="form-check-label custom-control-label"
for="move_on_open">{{ $__t('Move on open') }}&nbsp;<i class="fa-solid fa-question-circle text-muted"
data-toggle="tooltip"
data-trigger="hover click"
title="{{ $__t("When enabled, on marking this product as opened, the corresponding amount will be moved to the default consume location")
}}"></i>
</label>
</div>
@endif
</div> </div>
@else @else
<input type="hidden" <input type="hidden"