Commit a5826220f9e for woocommerce
commit a5826220f9e4e5b34ac0825b5fd21b6389b12b88
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Thu Mar 5 13:44:49 2026 +0300
[WOOPRD-2912] Fix duplicate fulfillment order notes by tracking property changes (#63544)
* fix: remove redundant save_meta_data() call in OrderFulfillmentsRestController
The update_fulfillment() method calls $fulfillment->save() followed by
$fulfillment->save_meta_data(). The DataStore's update() method already
calls save_meta_data() internally before firing the
woocommerce_fulfillment_after_update hook. The redundant call triggers
the hook a second time, causing FulfillmentOrderNotes to generate a
duplicate "Fulfillment #X updated" order note (since it clears its
previous_statuses tracking after the first fire).
* Add changefile(s) from automation for the following project(s): woocommerce
* Enhance fulfillment update process by tracking property changes
* Remove redundant escape characters from changelog
* test: enhance fulfillment update hook to include changed properties and old state
* Update hook documentation
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/63544-fix-wooprd-2912-remove-duplicate-save-meta-data b/plugins/woocommerce/changelog/63544-fix-wooprd-2912-remove-duplicate-save-meta-data
new file mode 100644
index 00000000000..486b03e27b2
--- /dev/null
+++ b/plugins/woocommerce/changelog/63544-fix-wooprd-2912-remove-duplicate-save-meta-data
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix duplicate "Fulfillment updated" order notes caused by redundant save_meta_data() call in OrderFulfillmentsRestController.
diff --git a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
index e1483608970..e631dbc512b 100644
--- a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
+++ b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
@@ -58,14 +58,19 @@ Fired after a fulfillment is successfully updated in the database.
**Parameters:**
- `$data` (Fulfillment) - The updated fulfillment object
+- `$changed_props` (array) - List of tracked property keys that changed (e.g. `'status'`, `'items'`, `'_tracking_number'`, `'_tracking_url'`, `'_shipping_provider'`)
+- `$old_state` (array) - Snapshot of tracked property values before the update
-**Purpose:** Allows plugins to perform actions after a fulfillment is updated.
+**Purpose:** Allows plugins to perform actions after a fulfillment is updated. Only tracked properties (status, items, tracking number, tracking URL, shipping provider) are compared; changes to other metadata do not appear in `$changed_props`.
```php
-add_action( 'woocommerce_fulfillment_after_update', 'sync_fulfillment_changes' );
+add_action( 'woocommerce_fulfillment_after_update', 'sync_fulfillment_changes', 10, 3 );
-function sync_fulfillment_changes( $fulfillment ) {
- // Sync changes to external fulfillment service
+function sync_fulfillment_changes( $fulfillment, $changed_props, $old_state ) {
+ // Only sync when tracking info changes
+ if ( in_array( '_tracking_number', $changed_props, true ) ) {
+ // Sync tracking to external service
+ }
}
```
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
index 4b7bc8b003a..62d7e82e968 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
@@ -24,6 +24,11 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data_Store_Interface, FulfillmentsDataStoreInterface {
+ /**
+ * Tracked meta keys for detecting meaningful fulfillment property changes.
+ */
+ private const TRACKED_META_KEYS = array( '_tracking_number', '_tracking_url', '_shipping_provider' );
+
/**
* Method to create a new fulfillment in the database.
*
@@ -188,6 +193,9 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
$this->validate_items( $data );
+ // Snapshot tracked properties before the update so we can detect changes.
+ $old_state = $this->snapshot_tracked_state( $data_id );
+
/**
* Filter to modify the fulfillment data before it is updated.
*
@@ -253,14 +261,20 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
$data->set_object_read( true );
if ( ! doing_action( 'woocommerce_fulfillment_after_update' ) ) {
+ $changed_props = $this->compute_changed_props( $data, $old_state );
+
/**
* Action to perform after a fulfillment is updated.
*
- * @param Fulfillment $data The fulfillment object that was updated.
+ * @param Fulfillment $data The fulfillment object that was updated.
+ * @param array $changed_props List of tracked property keys that changed
+ * (e.g. 'status', 'items', '_tracking_number').
+ * @param array $old_state Snapshot of tracked property values before the update.
*
* @since 10.1.0
+ * @since 10.7.0 Added $changed_props and $old_state parameters.
*/
- do_action( 'woocommerce_fulfillment_after_update', $data );
+ do_action( 'woocommerce_fulfillment_after_update', $data, $changed_props, $old_state );
}
if ( $is_fulfill_action && ! doing_action( 'woocommerce_fulfillment_after_fulfill' ) ) {
@@ -275,6 +289,58 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
}
}
+ /**
+ * Snapshot tracked properties of a fulfillment from the database.
+ *
+ * @param int $fulfillment_id The fulfillment ID.
+ * @return array The snapshot of tracked property values.
+ */
+ private function snapshot_tracked_state( int $fulfillment_id ): array {
+ $old = new Fulfillment( (string) $fulfillment_id );
+
+ $state = array(
+ 'status' => $old->get_status() ?? 'unfulfilled',
+ 'items' => $old->get_items(),
+ );
+
+ foreach ( self::TRACKED_META_KEYS as $key ) {
+ $value = $old->get_meta( $key, true );
+ $state[ $key ] = is_string( $value ) ? $value : '';
+ }
+
+ return $state;
+ }
+
+ /**
+ * Compute which tracked properties changed between the old state and the updated fulfillment.
+ *
+ * @param Fulfillment $fulfillment The updated fulfillment object.
+ * @param array $old_state The previous state snapshot.
+ * @return array List of changed property keys (e.g. 'status', 'items', '_tracking_number').
+ */
+ private function compute_changed_props( Fulfillment $fulfillment, array $old_state ): array {
+ $changed = array();
+
+ $new_status = $fulfillment->get_status() ?? 'unfulfilled';
+ if ( $new_status !== $old_state['status'] ) {
+ $changed[] = 'status';
+ }
+
+ if ( $fulfillment->get_items() !== $old_state['items'] ) {
+ $changed[] = 'items';
+ }
+
+ foreach ( self::TRACKED_META_KEYS as $key ) {
+ $new_value = $fulfillment->get_meta( $key, true );
+ $new_value = is_string( $new_value ) ? $new_value : '';
+ if ( $new_value !== $old_state[ $key ] ) {
+ $changed[] = $key;
+ }
+ }
+
+ return $changed;
+ }
+
/**
* Method to delete a fulfillment from the database.
*
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
index a9cd04018aa..2ea9aaf1ee9 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
@@ -23,20 +23,12 @@ use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
*/
class FulfillmentOrderNotes {
- /**
- * Stores the previous status of a fulfillment before update.
- *
- * @var array<int, string>
- */
- private array $previous_statuses = array();
-
/**
* Register hooks for fulfillment order notes.
*/
public function register(): void {
add_action( 'woocommerce_fulfillment_after_create', array( $this, 'add_fulfillment_created_note' ), 10, 1 );
- add_filter( 'woocommerce_fulfillment_before_update', array( $this, 'capture_previous_status' ), 10, 1 );
- add_action( 'woocommerce_fulfillment_after_update', array( $this, 'add_fulfillment_updated_note' ), 10, 1 );
+ add_action( 'woocommerce_fulfillment_after_update', array( $this, 'add_fulfillment_updated_note' ), 10, 3 );
add_action( 'woocommerce_fulfillment_after_delete', array( $this, 'add_fulfillment_deleted_note' ), 10, 1 );
}
@@ -94,49 +86,34 @@ class FulfillmentOrderNotes {
}
/**
- * Capture the previous status of a fulfillment before update.
+ * Add an order note when a fulfillment is updated.
*
- * This is hooked into `woocommerce_fulfillment_before_update` to record
- * the old status so we can detect status changes in the after_update hook.
+ * Only adds a note when tracked properties change (status, items,
+ * tracking number, tracking URL, shipping provider). If the status
+ * changed, a dedicated status change note is added instead.
*
- * @param Fulfillment $fulfillment The fulfillment object.
- * @return Fulfillment The unmodified fulfillment object.
+ * @param Fulfillment $fulfillment The fulfillment object.
+ * @param array $changed_props List of tracked property keys that changed.
+ * @param array $old_state Snapshot of tracked property values before the update.
*/
- public function capture_previous_status( Fulfillment $fulfillment ): Fulfillment {
- if ( $fulfillment->get_id() > 0 ) {
- $old_fulfillment = new Fulfillment( (string) $fulfillment->get_id() );
- $this->previous_statuses[ $fulfillment->get_id() ] = $old_fulfillment->get_status() ?? 'unfulfilled';
+ public function add_fulfillment_updated_note( Fulfillment $fulfillment, array $changed_props = array(), array $old_state = array() ): void {
+ if ( empty( $changed_props ) ) {
+ return;
}
- return $fulfillment;
- }
- /**
- * Add an order note when a fulfillment is updated.
- *
- * If the status changed, a status change note is added.
- * Otherwise, a general update note is added.
- *
- * @param Fulfillment $fulfillment The fulfillment object.
- */
- public function add_fulfillment_updated_note( Fulfillment $fulfillment ): void {
$order = $fulfillment->get_order();
if ( ! $order instanceof \WC_Order ) {
return;
}
- $fulfillment_id = $fulfillment->get_id();
- $old_status = $this->previous_statuses[ $fulfillment_id ] ?? null;
- $new_status = $fulfillment->get_status() ?? 'unfulfilled';
-
- // If status changed, add a status change note.
- if ( null !== $old_status && $old_status !== $new_status ) {
+ // If status changed, add a dedicated status change note.
+ if ( in_array( 'status', $changed_props, true ) ) {
+ $old_status = $old_state['status'] ?? 'unfulfilled';
+ $new_status = $fulfillment->get_status() ?? 'unfulfilled';
$this->add_fulfillment_status_changed_note( $fulfillment, $order, $old_status, $new_status );
- unset( $this->previous_statuses[ $fulfillment_id ] );
return;
}
- unset( $this->previous_statuses[ $fulfillment_id ] );
-
$items_text = $this->format_items( $fulfillment, $order );
$tracking_text = $this->format_tracking( $fulfillment );
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index 9d7cffed2c7..5773bf432bf 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -360,7 +360,6 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
}
}
$fulfillment->save();
- $fulfillment->save_meta_data();
if ( $notify_customer ) {
if ( ! $previous_state && $next_state ) {
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
index 2c76c8a518c..f33718ee7f6 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
@@ -217,19 +217,26 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
$fulfillment->save();
$this->assertNotNull( $fulfillment->get_id(), 'Fulfillment ID should not be null.' );
- $hook_called = false;
+ $hook_called = false;
+ $received_fulfillment = null;
+ $received_changed = null;
+ $received_old_state = null;
add_action(
'woocommerce_fulfillment_after_update',
- function ( $fulfillment ) use ( &$hook_called, &$received_fulfillment ) {
+ function ( $fulfillment, $changed_props, $old_state ) use ( &$hook_called, &$received_fulfillment, &$received_changed, &$received_old_state ) {
$received_fulfillment = $fulfillment;
+ $received_changed = $changed_props;
+ $received_old_state = $old_state;
$hook_called = true;
- return $fulfillment;
},
10,
- 2
+ 3
);
- // Add a modification to the saved fulfillment, so we can see the difference.
+ $old_status = $fulfillment->get_status();
+
+ // Modify a tracked property so changed_props is populated.
+ $fulfillment->set_status( 'fulfilled' );
$fulfillment->add_meta_data( 'test_meta_update', 'test_meta_value' );
$this->store->update( $fulfillment );
@@ -245,6 +252,13 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
$this->assertEquals( $received_fulfillment->get_meta( 'test_meta_key_2' ), $fulfillment->get_meta( 'test_meta_key_2' ) );
$this->assertEquals( $received_fulfillment->get_meta( 'test_meta_update' ), $fulfillment->get_meta( 'test_meta_update' ) );
$this->assertEquals( $received_fulfillment->get_items(), $fulfillment->get_items() );
+
+ // Verify changed_props and old_state are passed correctly.
+ $this->assertIsArray( $received_changed );
+ $this->assertContains( 'status', $received_changed );
+ $this->assertIsArray( $received_old_state );
+ $this->assertArrayHasKey( 'status', $received_old_state );
+ $this->assertEquals( $old_status, $received_old_state['status'] );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
index 305846f77ac..524e7d31373 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
@@ -69,7 +69,6 @@ class FulfillmentOrderNotesTest extends \WC_Unit_Test_Case {
*/
public function test_hooks_registered(): void {
$this->assertNotFalse( has_action( 'woocommerce_fulfillment_after_create' ) );
- $this->assertNotFalse( has_filter( 'woocommerce_fulfillment_before_update' ) );
$this->assertNotFalse( has_action( 'woocommerce_fulfillment_after_update' ) );
$this->assertNotFalse( has_action( 'woocommerce_fulfillment_after_delete' ) );
}