Skip to content

Fix parent product price cleared when variant price is changed - #2974

Open
dpfaffenbauer with Copilot wants to merge 6 commits into
5.0from
copilot/fix-parent-product-price-issue
Open

Fix parent product price cleared when variant price is changed#2974
dpfaffenbauer with Copilot wants to merge 6 commits into
5.0from
copilot/fix-parent-product-price-issue

Conversation

Copilot AI commented Feb 16, 2026

Copy link
Copy Markdown
Contributor

Changing a variant's price overwrites the parent product's price/VAT to 0.

Root cause

When a variant inherits its store values and something is changed, StoreValues::save() breaks the inheritance and gives the variant its own record, with clone $storeValuesEntity.

The clone is not the problem in itself — but it kept the id of the record it was copied from, and with it the identity of the parent's row.

  • Through Pimcore inheritance the variant's field holds the very same ProductStoreValues instance the parent product holds. Changing the price on the variant therefore modifies the parent's managed entity.
  • save() calls computeChangeSet() on it to detect the change, which registers a pending update in the Unit of Work. The following flush() writes the variant's price onto the parent's row.
  • In the admin path EntityMerger re-registers the clone under the parent's id with product pointing at the variant. The parent then has no own store values row at all and falls back to a price of 0, which is what the issue describes.

Fix

A copy of a store values record is a new record that belongs to nobody yet. ProductStoreValues now says so itself:

public function __clone()
{
    $this->id = null;
    $this->product = null;
    $this->productUnitDefinitionPrices = new ArrayCollection();
}

That is all three copy sites need. createDataCopy() loses its ReflectionClass block and is a plain clone; getDataFromEditmode() keeps its clone unchanged and is correct by construction; and the save() branch clones instead of copying field by field.

Expressing it on the entity rather than in the field definition matters for more than tidiness:

  • The properties are protected, so the model is the natural place to define what a copy is — no reflection from the outside, and nothing that PHP 8.5 deprecates (ReflectionProperty::setAccessible()).
  • coreshop.resources.product_store_values.classes.model is overridable. A project that replaces the model with its own subclass and adds persisted fields gets the right behaviour by default, and can extend __clone() for its own associations. Enumerating fields to copy would silently drop those fields the moment inheritance is broken.
  • It is the standard PHP/Doctrine idiom for prototype copies.

Two things remain in save(), both needed because the entity being copied is the parent's:

  • the variant gets its own ProductUnitDefinitionPrice instances (the originals stay with the parent's record, which is orphan-removal owned);
  • the inherited entity is rolled back to its persisted state and detached, so the change set computed above is not written onto the parent's row, and the parent's in-memory entity stays consistent with the database for the rest of the request.

Verification

Replaying the Unit of Work sequence against doctrine/orm 3.6.8, with a ProductStoreValues subclass that adds a persisted customField — as a project would:

parent row variant row
before (clone, identity kept) price 25000 (overwritten) price 25000, customField kept
factory + field copy price 25000 (overwritten) price 25000, customField lost
factory + field copy + refresh() price 25000 (overwritten) price 25000, customField lost
factory + field copy + rollback + detach() price 10000 (untouched) price 25000, customField lost
clone with __clone() + rollback + detach() price 10000 (untouched) price 25000, customField kept
== 5.0 as-is (clone, identity kept) ==
  store_values id=1 price=25000 product=1 customField='project-specific'
  store_values id=2 price=25000 product=2 customField='project-specific'
  unit_prices  id=1 price=500 unitDefinition=7 store_values=1
  scheduled UPDATEs on store_values before flush: store_values#1 ["price"]

== factory + field copy + restore + detach ==
  store_values id=1 price=10000 product=1 customField='project-specific'
  store_values id=2 price=25000 product=2 customField=NULL
  unit_prices  id=1 price=500 unitDefinition=7 store_values=1
  unit_prices  id=2 price=500 unitDefinition=7 store_values=2
  scheduled UPDATEs on store_values before flush: none

== clone (with __clone()) + restore + detach  <-- shipped, save() path ==
  store_values id=1 price=10000 product=1 customField='project-specific'
  store_values id=2 price=25000 product=2 customField='project-specific'
  unit_prices  id=1 price=500 unitDefinition=7 store_values=1
  unit_prices  id=2 price=500 unitDefinition=7 store_values=2
  scheduled UPDATEs on store_values before flush: none

== clone (with __clone()) in getDataFromEditmode()  <-- shipped, admin path ==
  store_values id=1 price=10000 product=1 customField='project-specific'
  store_values id=2 price=25000 product=2 customField='project-specific'
  unit_prices  id=1 price=500 unitDefinition=7 store_values=1
  unit_prices  id=2 price=500 unitDefinition=7 store_values=2
  scheduled UPDATEs on store_values before flush: none

refresh() does not help: the change set computed earlier survives it.

Tests

features/domain/product/product_store_values_inheritance.feature gets two scenarios that actually reach the branch. The existing steps could not: they go through setStoreValuesOfType(), which reads with InheritanceHelper::useInheritedValues(..., false) and therefore never operates on the parent's entity. The new setup step reads the store values with inheritance, changes the price and saves — the flow from the issue — and the assertions run against the products reloaded from the database.

Regression of #1486, previously patched in #1491 for CoreShop 2.2.

Replace clone of ProductStoreValues with new instance creation via factory
to prevent shared Doctrine identity (ID) from causing the parent's database
record to be updated when persisting the variant's store values.

Fixes the regression where changing a variant's price would reset the
parent product's price and VAT to 0.

Co-authored-by: dpfaffenbauer <5981845+dpfaffenbauer@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix clearing price and VAT on parent product when variant price changes Fix parent product price cleared when variant price is changed Feb 16, 2026
Copilot AI requested a review from dpfaffenbauer February 16, 2026 15:43
@sonarqubecloud

Copy link
Copy Markdown

…tance

Creating the new record via the factory alone is not enough: the inherited
entity is shared with the parent product, stays managed and keeps the change
set that broke the inheritance, so flush() writes those changes onto the
parent products record. Roll it back to its persisted state and detach it.

The new record also gets its own ProductUnitDefinitionPrice instances instead
of leaving them behind on the inherited entity.
@dpfaffenbauer
dpfaffenbauer marked this pull request as ready for review August 24, 2026 09:50
@coreshop-bot

coreshop-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🚦 PR Guardrail

⚠️ Advisory mode: this PR is not blocked (yet). Once the guardrail is switched to enforce: true, the points below would be violations.

❌ R1 — Branch name

Branch copilot/fix-parent-product-price-issue does not follow the issue/<number> scheme.

➡️ Rename the branch to issue/<issue-number> (e.g. issue/123 or issue/123-dam-import) and reopen the PR. The number must point to an issue in this repository.

ℹ️ Some CI checks are still running. The CI result is re-evaluated automatically once they finish.

Automated comment by the PR guardrail — details and FAQ: docs/GUARDRAILS.md in coreshop/workflow-collection.

…tance

Enumerating the fields to copy drops everything a project added to its own
ProductStoreValues model. Reuse the approach of createDataCopy() instead:
clone the record and drop its identity, which keeps every other field by
construction, and extract that into a shared helper.
A copy of a store values record is a new, unowned record. Saying that once in
__clone() replaces the reflection juggling in the field definition, keeps the
fields of a custom ProductStoreValues model by construction, and gives both
the programmatic and the admin path the same behaviour. Projects subclassing
the model can extend __clone() for their own associations.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Price and VAT are cleared on parent product when price of new created variant is changed

2 participants