Skip to content

Composite product created before its children are linked is latched out of stock permanently #41174

Description

@brosenberger

Preconditions (*)

  1. Magento Open Source 2.4-develop (measured at 3a6b9667), also reproducible on 2.4.8-p5 and 2.4.9
  2. Any composite product type: configurable, bundle or grouped

Summary

A composite product created before its children are linked is born with
is_in_stock = 0 and stock_status_changed_auto = 0, and can then never be
brought back into stock automatically. This is the ordinary order an ERP or PIM
integration writes in: a structure feed creates the parent, a later feed attaches
the children, and a stock feed fills in quantities last.

ChangeParentStockStatus::isNeedToUpdateParent() — identical in
Magento\ConfigurableProduct, Magento\Bundle and Magento\GroupedProduct
lets a parent go out of stock unconditionally, but only lets it come back when
stock_status_changed_auto is set:

return $parentStockItem->getIsInStock() !== $childrenIsInStock &&
    ($childrenIsInStock === false || $parentStockItem->getStockStatusChangedAuto());

The implicitly created stock item records no merchant decision, but a 0 in that
column is indistinguishable from a merchant having taken the product off sale, so
the parent is latched out of stock permanently. Reindexing does not help: the flag
is stored, not derived.

Steps to reproduce (*)

  1. POST /rest/V1/products — create a configurable, bundle or grouped parent with
    no stock_item extension attribute and no children linked yet.
  2. Read the parent's row:
    SELECT is_in_stock, stock_status_changed_auto
    FROM cataloginventory_stock_item si
    JOIN catalog_product_entity e ON e.entity_id = si.product_id
    WHERE e.sku = '<parent sku>';
    It is (0, 0).
  3. Create the children and attach them to the parent in a second call.
  4. Send stock for the children in a third call.
  5. Re-read the parent's row and its storefront salability.

Expected result (*)

  1. The parent follows its children back into stock once they are salable.

Actual result (*)

  1. The parent stays (is_in_stock = 0, stock_status_changed_auto = 0) and is not
    salable, permanently. Measured for all three composite types.

Important boundary

The parent must be created before its children are linked. If one call creates
the parent already carrying its links or options, ChangeParentStockStatus runs
inside that same save, the parent is born (0, 1), and it recovers normally. That
is why this looks intermittent between integrations: one that sends structure and
links together never sees it.

Additional information

The asymmetry is visible in StockItemRepository::save(). The qty branch maps the
"changed automatically" marker onto the column; the non-qty (composite) branch
never touches the column at all:

if ($isQty) {
    …
    if ($stockItem->hasStockStatusChangedAutomaticallyFlag()) {
        $stockItem->setStockStatusChangedAuto((int)$stockItem->getStockStatusChangedAutomaticallyFlag());
    }
} else {
    $stockItem->setQty(0);
}

StockConfigurationInterface::isQty() is false for exactly configurable,
bundle and grouped (only simple, virtual and downloadable declare
isQty="true"), so that branch already isolates the composite types.

Upstream already applies the same rule elsewhere:
InventoryConfigurableProduct\Model\StockStatusManagement::update() derives the
flag for a newly created stock item — but only for configurables, only in single
source mode, and it computes 0 for the born-out-of-stock case.

Related, all describing the same latch from different entry points:
#36154, #37960, #32192.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions