-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Copy all prices from master over children variants #3994
Copy all prices from master over children variants #3994
Conversation
c6bb075
to
7a54da9
Compare
This commit changes functionalities in three ways: - Backend: Before this change, when a user created a new variant the form was pre-populated only with a single price field for the default currency. In the case the user removed the amount field and submitted the form, the created price had an amount equal to 0. Now, all the prices created for a given product (for a given master variant, in fact) get pre-populated in a nested form. In the case one of the amounts is removed by the user, the form becomes invalid on submission and forces the user to set an amount for every price. - API: Before this change, the user was able to create a variant 1) manually assigning a single price in the default currency through the `price` attribute, and 2) letting it inherit the default price from the master variant when `price` attribute was not present. Now, when not overridden all master prices get inherited. Otherwise, the user can specify both through `price` (for the default currency) and `prices_attributes` attributes. - Product duplicator: Before this change, only the default master price was copied. Now, all prices are taken into account. Internally, the `set_price` callback on `Variant` has been removed in order to allow that flexibility. The behavior has been moved to a layer one step closer than where it should actually be: A service layer. It will help when we tackle this long-term goal. It also means that when we manually create a variant we must specify the default price for it. Having three rails associations between `Variant` and `Price` is a source of inconsistencies, as when we add a `Price` in-memory to one of them the others don't get updated. It should be changed as follow-up work. For now, `Variant#any_price?` hack has been added. In the same way, `Variant#find_or_build_default_price` didn't find it when it still was in-memory. `Variant#default_price_from_memory` have been added to fix it.
7a54da9
to
aba70db
Compare
Having `currently_valid_prices` as an association between `Variant` and `Price` made it a source of inconsistencies with the undecorated `prices` association: ``` Spree::Variant.new.tap do |v| v.currently_valid_prices << Spree::Price.new end.prices.any? # => false ``` We're adding tests to the method, making explicit that at this moment a price that belongs to a country is prioritized over a generic price when it comes to choose which one is showed as the default. Probably it's not desired behavior and we should change it in a follow-up work. We should also consider renaming it to something more communicative. The current name conveys the idea that we're filtering some records out, while in fact we're just ordering them.
I added two commits addressing the concerns about redundant associations Move currently_valid_prices to a method Having
We're adding tests to the method, making explicit that at this moment a We should also consider renaming it to something more communicative. The Simplify Before this work, include Spree
Variant.new(price: Price.new) # price delegates to default_price
Variant.prices # => [] From now on, We have renamed previous method No longer being a Rails association makes it impossible to use the default SELECT "spree_products".* FROM "spree_products" LEFT OUTER JOIN
"spree_variants" ON "spree_variants"."is_master" = ? AND "spree
_variants"."product_id" = "spree_products"."id" LEFT OUTER JOIN
"spree_prices" ON "spree_prices"."currency" = ? AND "spree_pric
es"."country_iso" IS NULL AND "spree_prices"."variant_id" =
"spree_variants"."id" WHERE "spree_products"."deleted_at" IS NULL O
RDER BY "spree_prices"."amount" ASC, "spree_products"."id" ASC LIMIT ?
OFFSET ? [["is_master", 1], ["currency", "USD"], ["LIMI T", 10],
["OFFSET", 0]] However, the new query doesn't include discarded prices on the result, SELECT "spree_products".* FROM "spree_products" LEFT OUTER JOIN
"spree_variants" "master" ON "master"."is_master" = ? AND "mast
er"."product_id" = "spree_products"."id" LEFT OUTER JOIN "spree_prices"
"prices" ON "prices"."deleted_at" IS NULL AND "prices". "variant_id" =
"master"."id" LEFT OUTER JOIN "spree_variants" "masters_spree_products"
ON "masters_spree_products"."is_master" = ? AND
"masters_spree_products"."product_id" = "spree_products"."id" WHERE
"spree_products"."deleted_at" IS NULL AND "prices". "currency" = ? AND
"prices"."country_iso" IS NULL AND "prices"."deleted_at" IS NULL ORDER
BY prices.amount DESC, "spree_product s"."id" ASC LIMIT ? OFFSET ?
[["is_master", 1], ["is_master", 1], ["currency", "USD"], ["LIMIT", 10],
["OFFSET", 0]] To study this logic made me realize that the
On top of that, probably it doesn't make sense to allow having different As follow-up work I'd propose:
|
6fea3f0
to
d5df6e3
Compare
Before this work, `#default_price` was a `has_one` association between `Variant` and `Product`. As we already have a `has_many` association between both models, this redundancy was a source of inconsistencies. I.g.: ```ruby include Spree Variant.new(price: Price.new) # price delegates to default_price Variant.prices # => [] ``` From now on, `#default_price` is a regular method that searches within `#prices` taking into account the criteria for a price to be considered the default one. We have renamed previous method `#find_default_price_or_build` to `default_price_or_build`. The latter feels less standard according to Rails conventions, but the intention here is, precisely, to communicate that this is not a Rails association. No longer being a Rails association makes it impossible to use the default ransack conventions to build the sort-by-price link on the products listing page. For this reason, we added `sort_by_master_default_price_amount_{asc, desc}` scopes to the `Price` model, which is automatically picked up by ransack. As it's now explicit, these queries ignore the `ORDER BY` clauses implicit in the `currently_valid_prices` method, but this was also happening in the query built by ransack from the `has_one` association: ```SQL SELECT "spree_products".* FROM "spree_products" LEFT OUTER JOIN "spree_variants" ON "spree_variants"."is_master" = ? AND "spree _variants"."product_id" = "spree_products"."id" LEFT OUTER JOIN "spree_prices" ON "spree_prices"."currency" = ? AND "spree_pric es"."country_iso" IS NULL AND "spree_prices"."variant_id" = "spree_variants"."id" WHERE "spree_products"."deleted_at" IS NULL O RDER BY "spree_prices"."amount" ASC, "spree_products"."id" ASC LIMIT ? OFFSET ? [["is_master", 1], ["currency", "USD"], ["LIMI T", 10], ["OFFSET", 0]] ``` However, the new query doesn't include discarded prices on the result, but I think it's something desirable: ```SQL SELECT "spree_products".* FROM "spree_products" LEFT OUTER JOIN "spree_variants" "master" ON "master"."is_master" = ? AND "mast er"."product_id" = "spree_products"."id" LEFT OUTER JOIN "spree_prices" "prices" ON "prices"."deleted_at" IS NULL AND "prices". "variant_id" = "master"."id" LEFT OUTER JOIN "spree_variants" "masters_spree_products" ON "masters_spree_products"."is_master" = ? AND "masters_spree_products"."product_id" = "spree_products"."id" WHERE "spree_products"."deleted_at" IS NULL AND "prices". "currency" = ? AND "prices"."country_iso" IS NULL AND "prices"."deleted_at" IS NULL ORDER BY prices.amount DESC, "spree_product s"."id" ASC LIMIT ? OFFSET ? [["is_master", 1], ["is_master", 1], ["currency", "USD"], ["LIMIT", 10], ["OFFSET", 0]] ``` To study this logic made me realize that the `#default_price` logic is kind of weird. The fact that it includes discarded prices makes little sense in my view (other than being able to get the default price that a discarded variant had, but as far as I can see there's no GUI for discarded models). The order criteria neither make sense. Taking the `updated_at` attribute to prioritize could lead to undesired behavior: - Create a product with a default price. - Add another price in the same currency to override the previous one. - Update the previous price for some reason. - Now, the previous price becomes the default. On top of that, probably it doesn't make sense to allow having different prices for the same country & currency tuple. As follow-up work I'd propose: - Stop including discarded prices. - Validate that they can't coexist two prices with the same currency/country tuple, and remove the ordering criteria for default prices. We can provide more flexibility adding an `active` flag and scoping the validation to the case it's `true`.
d5df6e3
to
0e4b484
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks Marc, I left some comments after a first pass.
probably it doesn't make sense to allow having different prices for the same country & currency tuple.
I think this is done intentionally. There are other combinations of things that may determine the price in the same country/currency. For example if someone builds their own pricing strategy to allow different prices based on the role (eg. B2B vs B2C), so I guess we should keep that constraint.
The other considerations feel quite right, can't imagine a reason to keep discarded prices in the query and the default set by the updated_at
is weird.
@@ -23,6 +23,13 @@ def self.default_pricing | |||
end | |||
end | |||
|
|||
# Returns `#prices` prioritized for being considered as default price | |||
# | |||
# @return [Array<Spree::Price>] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
currently_valid
is a regular scope so it returns an ActiveRecord::Relation
, isn't it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right! Fixed.
result = described_class.currently_valid | ||
|
||
expect( | ||
result.index(price_1) < result.index(price_2) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think
expect(described_class.currently_valid).to eq [price_1, price_2]
has the same effect also taking the order into account. Do you prefer this version because it's more explicit?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it was my first try. The problem is that the fixtures are not very well optimized and other Price
records get being created, so the result would contain other items. I think it should be food for another PR...
Fixes bug where discarded prices were taken into account in order to tell the default price for a variant. The rationale behind the previous behavior was to be able to render a price when discarded products are included in the backend products listing page (see 'Show deleted' checkbox near the top-right edge). It was introduced in commit 87cef09. However, this logic was incorrect in two ways: - It affected non-discarded variants, where we don't want to consider discarded prices. - Even for discarded variants, it could lead to display the wrong information. As all prices get discarded when a variant is discarded, there's no guarantee that the one that is rendered was the previously considered as the default. The list of products on the backend will render the price field for discarded products as blank. In case we want to track default prices, we should add a flag to uniquely identify them.
Until now the time of update was instead taken into account. This could lead to an incorrect result: - Create price 1 - Try to supersede price 1 creating price 2 - Update price 1 for some reason - At this point, price 1 was considered as the default
It conveys better what it's doing, as the query only consists of `ORDER` clauses and no record is filtered out.
7ad44f4
to
db31154
Compare
I see... However it makes the default price concept as it's right now quite vague. I think we should revisit it at some point.
I added three more commits. One to ignore discarded prices, another one changing the prioritization to take creation time instead of update time, and a third one renaming Ignore discarded prices on Variant#default_price Fixes bug where discarded prices were taken into account in order to The rationale behind the previous behavior was to be able to render a However, this logic was incorrect in two ways:
The list of products on the backend will render the price field for Prioritize by creation date on default_price Until now the time of update was instead taken into account. This could
** Rename currently_valid to prioritized_for_default ** It conveys better what it's doing, as the query only consists of |
We're closing it as we'll be revisiting this stuff in the context of services objects. |
Description
This commit changes functionalities in three ways:
Backend: Before this change, when a user created a new variant the
form was pre-populated only with a single price field for the default
currency. In the case the user removed the amount field and submitted
the form, the created price had an amount equal to 0. Now, all the
prices created for a given product (for a given master variant, in
fact) get pre-populated in a nested form. In the case one of the
amounts is removed by the user, the form becomes invalid on submission
and forces the user to set an amount for every price.
API: Before this change, the user was able to create a variant 1)
manually assigning a single price in the default currency through the
price
attribute, and 2) letting it inherit the default price fromthe master variant when
price
attribute was not present. Now, whennot overridden all master prices get inherited. Otherwise, the user
can specify both through
price
(for the default currency) andprices_attributes
attributes.Product duplicator: Before this change, only the default master price
was copied. Now, all prices are taken into account.
Internally, the
set_price
callback onVariant
has been removed inorder to allow that flexibility. The behavior has been moved to a layer
one step closer than where it should actually be: A service layer. It
will help when we tackle this long-term goal. It also means that when we
manually create a variant we must specify the default price for it.
Having three rails associations between
Variant
andPrice
is asource of inconsistencies, as when we add a
Price
in-memory to one ofthem the others don't get updated. It should be changed as follow-up
work. For now,
Variant#any_price?
hack has been added. In the sameway,
Variant#find_or_build_default_price
didn't find it when it stillwas in-memory.
Variant#default_price_from_memory
have been added tofix it.
There are still two considerations that
Variant#default_price_from_memory
is not taking into account, but I'd appreciate some feedback. Take a look at thedefault_price
definition:solidus/core/app/models/concerns/spree/default_price.rb
Lines 8 to 14 in 3cc2d3e
currently_valid
is showing prices with a country associated to it in the first place. This seems weird, as I'd think as the default price that one with no country associated to it:solidus/core/app/models/spree/price.rb
Line 23 in 3cc2d3e
This change was introduced in 9af171f , but doesn't seem to be justified.
Demo
screencast-localhost_3000-2021.03.16-06_59_49.mp4
Checklist: