Implement "Free products" (closes #426)

This commit is contained in:
Bernd Bestel 2020-01-26 20:01:30 +01:00
parent f09ba08549
commit 71a57c9dcb
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
12 changed files with 154 additions and 12 deletions

View File

@ -20,6 +20,12 @@
- On using "Consume all ingredients needed by this recipe" and when it has a product attached, one unit of that product (per serving in purchase quantity unit) will be added to stock (with the proper price based on the recipe ingredients) - On using "Consume all ingredients needed by this recipe" and when it has a product attached, one unit of that product (per serving in purchase quantity unit) will be added to stock (with the proper price based on the recipe ingredients)
- (Thanks @kriddles for the intial work on this) - (Thanks @kriddles for the intial work on this)
### New feature: Freeze/Thaw products
- New product options "Default best before days after freezing/thawing" to set how the best before date should be changed on freezing/thawing
- New location option "Is freezer" to indicate if the location is a freezer
- => When moving a product from/to a freezer location, the best before date is changed accordingly
- There is also a new sub feature flag `FEATURE_FLAG_STOCK_PRODUCT_FREEZING` to disable this if you don't need it (defaults to `true`)
### Stock improvements/fixes ### Stock improvements/fixes
- The productcard gets now also refreshed after a transaction was posted (purchase/consume/etc.) (thanks @kriddles) - The productcard gets now also refreshed after a transaction was posted (purchase/consume/etc.) (thanks @kriddles)
- The product field calories (kcal) now also allows decimal numbers - The product field calories (kcal) now also allows decimal numbers

View File

@ -136,6 +136,7 @@ Setting('FEATURE_FLAG_STOCK_PRICE_TRACKING', true);
Setting('FEATURE_FLAG_STOCK_LOCATION_TRACKING', true); Setting('FEATURE_FLAG_STOCK_LOCATION_TRACKING', true);
Setting('FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING', true); Setting('FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING', true);
Setting('FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING', true); Setting('FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING', true);
Setting('FEATURE_FLAG_STOCK_PRODUCT_FREEZING', true);
Setting('FEATURE_FLAG_SHOPPINGLIST_MULTIPLE_LISTS', true); Setting('FEATURE_FLAG_SHOPPINGLIST_MULTIPLE_LISTS', true);
Setting('FEATURE_FLAG_CHORES_ASSIGNMENTS', true); Setting('FEATURE_FLAG_CHORES_ASSIGNMENTS', true);

View File

@ -3467,8 +3467,11 @@
"location_id": { "location_id": {
"type": "integer" "type": "integer"
}, },
"name": { "location_name": {
"type": "string" "type": "string"
},
"location_is_freezer": {
"type": "integer"
} }
}, },
"example": { "example": {

View File

@ -330,3 +330,6 @@ msgstr ""
msgid "This is a note" msgid "This is a note"
msgstr "" msgstr ""
msgid "Freezer"
msgstr ""

View File

@ -1681,3 +1681,30 @@ msgstr ""
msgid "Scan mode is on but not all required fields could be populated automatically" msgid "Scan mode is on but not all required fields could be populated automatically"
msgstr "" msgstr ""
msgid "Is freezer"
msgstr ""
msgid "When moving products from/to a freezer location, the products best before date is automatically adjusted according to the product settings"
msgstr ""
msgid "On moving this product to a freezer location, the best before date will be replaced by today + this amount of days"
msgstr ""
msgid "Default best before days after freezing"
msgstr ""
msgid "On moving this product from a freezer location, the best before date will be replaced by today + this amount of days"
msgstr ""
msgid "Default best before days after thawing"
msgstr ""
msgid "This cannot be the same as the \"From\" location"
msgstr ""
msgid "Thawed"
msgstr ""
msgid "Frozen"
msgstr ""

31
migrations/0097.sql Normal file
View File

@ -0,0 +1,31 @@
ALTER TABLE products
ADD default_best_before_days_after_freezing INTEGER NOT NULL DEFAULT 0;
UPDATE products
SET default_best_before_days_after_freezing = 0;
ALTER TABLE products
ADD default_best_before_days_after_thawing INTEGER NOT NULL DEFAULT 0;
UPDATE products
SET default_best_before_days_after_thawing = 0;
ALTER TABLE locations
ADD is_freezer TINYINT NOT NULL DEFAULT 0;
UPDATE locations
SET is_freezer = 0;
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,
l.is_freezer AS location_is_freezer
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,4 +1,4 @@
$('#save-transfer-button').on('click', function(e) $('#save-transfer-button').on('click', function (e)
{ {
e.preventDefault(); e.preventDefault();
@ -71,10 +71,18 @@
} }
else else
{ {
Grocy.FrontendHelpers.EndUiBusy("transfer-form"); Grocy.FrontendHelpers.EndUiBusy("transfer-form");
toastr.success(successMessage); toastr.success(successMessage);
if (parseInt($("#location_id_from option:selected").attr("data-is-freezer")) === 0 && parseInt($("#location_id_to option:selected").attr("data-is-freezer")) === 1) // Frozen
{
toastr.info('<span>' + __t("Frozen") + "</span> <i class='fas fa-snowflake'></i>");
}
if (parseInt($("#location_id_from option:selected").attr("data-is-freezer")) === 1 && parseInt($("#location_id_to option:selected").attr("data-is-freezer")) === 0) // Thawed
{
toastr.info('<span>' + __t("Thawed") + "</span> <i class='fas fa-fire-alt'></i>");
}
$("#specific_stock_entry").find("option").remove().end().append("<option></option>"); $("#specific_stock_entry").find("option").remove().end().append("<option></option>");
$("#specific_stock_entry").attr("disabled", ""); $("#specific_stock_entry").attr("disabled", "");
$("#specific_stock_entry").removeAttr("required"); $("#specific_stock_entry").removeAttr("required");
@ -151,7 +159,8 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
{ {
$("#location_id_from").append($("<option>", { $("#location_id_from").append($("<option>", {
value: stockLocation.location_id, value: stockLocation.location_id,
text: stockLocation.location_name + " (" + __t("Default location") + ")" text: stockLocation.location_name + " (" + __t("Default location") + ")",
"data-is-freezer": stockLocation.location_is_freezer
})); }));
$("#location_id_from").val(productDetails.location.id); $("#location_id_from").val(productDetails.location.id);
$("#location_id_from").trigger('change'); $("#location_id_from").trigger('change');
@ -161,7 +170,8 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
{ {
$("#location_id_from").append($("<option>", { $("#location_id_from").append($("<option>", {
value: stockLocation.location_id, value: stockLocation.location_id,
text: stockLocation.location_name text: stockLocation.location_name,
"data-is-freezer": stockLocation.location_is_freezer
})); }));
} }

View File

@ -39,6 +39,7 @@ class DemoDataGeneratorService extends BaseService
INSERT INTO locations (name) VALUES ('{$this->__t_sql('Pantry')}'); --3 INSERT INTO locations (name) VALUES ('{$this->__t_sql('Pantry')}'); --3
INSERT INTO locations (name) VALUES ('{$this->__t_sql('Candy cupboard')}'); --4 INSERT INTO locations (name) VALUES ('{$this->__t_sql('Candy cupboard')}'); --4
INSERT INTO locations (name) VALUES ('{$this->__t_sql('Tinned food cupboard')}'); --5 INSERT INTO locations (name) VALUES ('{$this->__t_sql('Tinned food cupboard')}'); --5
INSERT INTO locations (name, is_freezer) VALUES ('{$this->__t_sql('Freezer')}', 1); --6
DELETE FROM quantity_units WHERE name = '{$this->__t_sql('Glass')}'; DELETE FROM quantity_units WHERE name = '{$this->__t_sql('Glass')}';
INSERT INTO quantity_units (id, name, name_plural) VALUES (4, '{$this->__n_sql(1, 'Glass', 'Glasses')}', '{$this->__n_sql(2, 'Glass', 'Glasses')}'); --4 INSERT INTO quantity_units (id, name, name_plural) VALUES (4, '{$this->__n_sql(1, 'Glass', 'Glasses')}', '{$this->__n_sql(2, 'Glass', 'Glasses')}'); --4
@ -86,7 +87,7 @@ class DemoDataGeneratorService extends BaseService
INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id) VALUES ('{$this->__t_sql('Sieved tomatoes')}', 5, 5, 5, 1, 3); --17 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id) VALUES ('{$this->__t_sql('Sieved tomatoes')}', 5, 5, 5, 1, 3); --17
INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id) VALUES ('{$this->__t_sql('Salami')}', 2, 3, 3, 1, 6); --18 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id) VALUES ('{$this->__t_sql('Salami')}', 2, 3, 3, 1, 6); --18
INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id) VALUES ('{$this->__t_sql('Toast')}', 3, 5, 5, 1, 2); --19 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id) VALUES ('{$this->__t_sql('Toast')}', 3, 5, 5, 1, 2); --19
INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id) VALUES ('{$this->__t_sql('Minced meat')}', 2, 3, 3, 1, 4); --20 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, default_best_before_days_after_freezing, default_best_before_days_after_thawing) VALUES ('{$this->__t_sql('Minced meat')}', 2, 3, 3, 1, 4, 180, 2); --20
INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, enable_tare_weight_handling, tare_weight, calories) VALUES ('{$this->__t_sql('Flour')}', 3, 8, 8, 1, 3, 1, 500, 2); --21 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, enable_tare_weight_handling, tare_weight, calories) VALUES ('{$this->__t_sql('Flour')}', 3, 8, 8, 1, 3, 1, 500, 2); --21
INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, calories) VALUES ('{$this->__t_sql('Sugar')}', 3, 3, 3, 1, 3, 4); --22 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, calories) VALUES ('{$this->__t_sql('Sugar')}', 3, 3, 3, 1, 3, 4); --22
INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, calories) VALUES ('{$this->__t_sql('Milk')}', 2, 10, 10, 1, 6, 1); --23 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock, product_group_id, calories) VALUES ('{$this->__t_sql('Milk')}', 2, 10, 10, 1, 6, 1); --23

View File

@ -464,6 +464,26 @@ class StockService extends BaseService
break; break;
} }
$newBestBeforeDate = $stockEntry->best_before_date;
if (GROCY_FEATURE_FLAG_STOCK_PRODUCT_FREEZING)
{
$locationFrom = $this->Database->locations()->where('id', $locationIdFrom)->fetch();
$locationTo = $this->Database->locations()->where('id', $locationIdTo)->fetch();
// Product was moved from a non-freezer to freezer location -> freeze
if (intval($locationFrom->is_freezer) === 0 && intval($locationTo->is_freezer) === 1 && $productDetails->product->default_best_before_days_after_freezing > 0)
{
$newBestBeforeDate = date("Y-m-d", strtotime('+' . $productDetails->product->default_best_before_days_after_freezing . ' days'));
}
// Product was moved from a freezer to non-freezer location -> thaw
if (intval($locationFrom->is_freezer) === 1 && intval($locationTo->is_freezer) === 0 && $productDetails->product->default_best_before_days_after_thawing > 0)
{
$newBestBeforeDate = date("Y-m-d", strtotime('+' . $productDetails->product->default_best_before_days_after_thawing . ' days'));
}
}
$correlationId = uniqid(); $correlationId = uniqid();
if ($amount >= $stockEntry->amount) // Take the whole stock entry if ($amount >= $stockEntry->amount) // Take the whole stock entry
{ {
@ -485,7 +505,7 @@ class StockService extends BaseService
$logRowForLocationTo = $this->Database->stock_log()->createRow(array( $logRowForLocationTo = $this->Database->stock_log()->createRow(array(
'product_id' => $stockEntry->product_id, 'product_id' => $stockEntry->product_id,
'amount' => $stockEntry->amount, 'amount' => $stockEntry->amount,
'best_before_date' => $stockEntry->best_before_date, 'best_before_date' => $newBestBeforeDate,
'purchased_date' => $stockEntry->purchased_date, 'purchased_date' => $stockEntry->purchased_date,
'stock_id' => $stockEntry->stock_id, 'stock_id' => $stockEntry->stock_id,
'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_TO, 'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_TO,
@ -498,7 +518,8 @@ class StockService extends BaseService
$logRowForLocationTo->save(); $logRowForLocationTo->save();
$stockEntry->update(array( $stockEntry->update(array(
'location_id' => $locationIdTo 'location_id' => $locationIdTo,
'best_before_date' => $newBestBeforeDate
)); ));
$amount -= $stockEntry->amount; $amount -= $stockEntry->amount;
@ -525,7 +546,7 @@ class StockService extends BaseService
$logRowForLocationTo = $this->Database->stock_log()->createRow(array( $logRowForLocationTo = $this->Database->stock_log()->createRow(array(
'product_id' => $stockEntry->product_id, 'product_id' => $stockEntry->product_id,
'amount' => $amount, 'amount' => $amount,
'best_before_date' => $stockEntry->best_before_date, 'best_before_date' => $newBestBeforeDate,
'purchased_date' => $stockEntry->purchased_date, 'purchased_date' => $stockEntry->purchased_date,
'stock_id' => $stockEntry->stock_id, 'stock_id' => $stockEntry->stock_id,
'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_TO, 'transaction_type' => self::TRANSACTION_TYPE_TRANSFER_TO,
@ -546,7 +567,7 @@ class StockService extends BaseService
$stockEntryNew = $this->Database->stock()->createRow(array( $stockEntryNew = $this->Database->stock()->createRow(array(
'product_id' => $stockEntry->product_id, 'product_id' => $stockEntry->product_id,
'amount' => $amount, 'amount' => $amount,
'best_before_date' => $stockEntry->best_before_date, 'best_before_date' => $newBestBeforeDate,
'purchased_date' => $stockEntry->purchased_date, 'purchased_date' => $stockEntry->purchased_date,
'stock_id' => $stockEntry->stock_id, 'stock_id' => $stockEntry->stock_id,
'price' => $stockEntry->price, 'price' => $stockEntry->price,

View File

@ -32,6 +32,20 @@
<textarea class="form-control" rows="2" id="description" name="description">@if($mode == 'edit'){{ $location->description }}@endif</textarea> <textarea class="form-control" rows="2" id="description" name="description">@if($mode == 'edit'){{ $location->description }}@endif</textarea>
</div> </div>
@if(GROCY_FEATURE_FLAG_STOCK_PRODUCT_FREEZING)
<div class="form-group">
<div class="form-check">
<input type="hidden" name="is_freezer" value="0">
<input @if($mode == 'edit' && $location->is_freezer == 1) checked @endif class="form-check-input" type="checkbox" id="is_freezer" name="is_freezer" value="1">
<label class="form-check-label" for="is_freezer">{{ $__t('Is freezer') }}
<span class="text-muted small">{{ $__t('When moving products from/to a freezer location, the products best before date is automatically adjusted according to the product settings') }}</span>
</label>
</div>
</div>
@else
<input type="hidden" name="is_freezer" value="0">
@endif
@include('components.userfieldsform', array( @include('components.userfieldsform', array(
'userfields' => $userfields, 'userfields' => $userfields,
'entity' => 'locations' 'entity' => 'locations'

View File

@ -227,6 +227,31 @@
)) ))
@endif @endif
@if(GROCY_FEATURE_FLAG_STOCK_PRODUCT_FREEZING)
@php if($mode == 'edit') { $value = $product->default_best_before_days_after_freezing; } else { $value = 0; } @endphp
@include('components.numberpicker', array(
'id' => 'default_best_before_days_after_freezing',
'label' => 'Default best before days after freezing',
'min' => -1,
'value' => $value,
'invalidFeedback' => $__t('The amount cannot be lower than %s', '0'),
'hint' => $__t('On moving this product to a freezer location, the best before date will be replaced by today + this amount of days')
))
@php if($mode == 'edit') { $value = $product->default_best_before_days_after_thawing; } else { $value = 0; } @endphp
@include('components.numberpicker', array(
'id' => 'default_best_before_days_after_thawing',
'label' => 'Default best before days after thawing',
'min' => -1,
'value' => $value,
'invalidFeedback' => $__t('The amount cannot be lower than %s', '0'),
'hint' => $__t('On moving this product from a freezer location, the best before date will be replaced by today + this amount of days')
))
@else
<input type="hidden" name="default_best_before_days_after_freezing" value="0">
<input type="hidden" name="default_best_before_days_after_thawing" value="0">
@endif
<div class="form-group"> <div class="form-group">
<label for="product-picture">{{ $__t('Product picture') }} <label for="product-picture">{{ $__t('Product picture') }}
<span class="text-muted small">{{ $__t('If you don\'t select a file, the current picture will not be altered') }}</span> <span class="text-muted small">{{ $__t('If you don\'t select a file, the current picture will not be altered') }}</span>

View File

@ -29,7 +29,7 @@
<select required class="form-control location-combobox" id="location_id_from" name="location_id_from"> <select required class="form-control location-combobox" id="location_id_from" name="location_id_from">
<option></option> <option></option>
@foreach($locations as $location) @foreach($locations as $location)
<option value="{{ $location->id }}">{{ $location->name }}</option> <option value="{{ $location->id }}" data-is-freezer="{{ $location->is_freezer }}">{{ $location->name }}</option>
@endforeach @endforeach
</select> </select>
<div class="invalid-feedback">{{ $__t('A location is required') }}</div> <div class="invalid-feedback">{{ $__t('A location is required') }}</div>
@ -66,7 +66,7 @@
<select required class="form-control location-combobox" id="location_id_to" name="location_id_to"> <select required class="form-control location-combobox" id="location_id_to" name="location_id_to">
<option></option> <option></option>
@foreach($locations as $location) @foreach($locations as $location)
<option value="{{ $location->id }}">{{ $location->name }}</option> <option value="{{ $location->id }}" data-is-freezer="{{ $location->is_freezer }}">{{ $location->name }}</option>
@endforeach @endforeach
</select> </select>
<div class="invalid-feedback">{{ $__t('A location is required') }}</div> <div class="invalid-feedback">{{ $__t('A location is required') }}</div>