| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS sync product serializer. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\Sync |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Sync; |
| 9 |
|
| 10 |
use WC_Product; |
| 11 |
use WC_Product_Variation; |
| 12 |
use WC_REST_Product_Variations_Controller; |
| 13 |
use WC_REST_Products_Controller; |
| 14 |
use WP_REST_Request; |
| 15 |
|
| 16 |
/** |
| 17 |
* THE product-record assembly line. |
| 18 |
* |
| 19 |
* Every sync surface that needs a single product (or variation) document builds |
| 20 |
* it here: the changes revision-hash walk, the barcode resolver, the targeted |
| 21 |
* variations read and the write acknowledgement. Before this class each of those |
| 22 |
* pasted the same four lines — controller instantiation, `prepare_object_for_response`, |
| 23 |
* `rest_get_server()->response_to_data()`, then the augmentation filter — and the |
| 24 |
* copies drifted (see the namespace-resolution comment the variations controller |
| 25 |
* used to carry). |
| 26 |
* |
| 27 |
* Two rules the pasted blocks encoded and this class keeps: |
| 28 |
* |
| 29 |
* 1. ADR 0003 — values come from the FILTERED WC REST representation, never a raw |
| 30 |
* projection. `prepare_object_for_response` runs WooCommerce's own |
| 31 |
* `woocommerce_rest_prepare_product_object` filter; `response_to_data` resolves |
| 32 |
* embedded links exactly as a real request would. |
| 33 |
* 2. Variations are serialized through WooCommerce's own VARIATIONS controller |
| 34 |
* (`WC_REST_Product_Variations_Controller`), products through the products |
| 35 |
* controller — this class picks per object type so a caller cannot pick |
| 36 |
* wrong. Hydrating a variation through the PRODUCTS controller is the #1710 |
| 37 |
* incident (`images[]` instead of `image`: blank POS thumbnails, the |
| 38 |
* parent's image on every order line); post-#1710 the payload species is |
| 39 |
* the variations controller's everywhere. |
| 40 |
*/ |
| 41 |
final class Product_Serializer { |
| 42 |
/** |
| 43 |
* The WooCommerce products controller, created on first use. |
| 44 |
* |
| 45 |
* Memoized because the callers serialize in LOOPS (a revision-hash page, an |
| 46 |
* include-set of variations); instantiating a controller per record was never |
| 47 |
* the intent of the pasted blocks — each of them hoisted it out of the loop. |
| 48 |
* |
| 49 |
* @var null|WC_REST_Products_Controller |
| 50 |
*/ |
| 51 |
private $controller = null; |
| 52 |
|
| 53 |
/** |
| 54 |
* The WooCommerce product VARIATIONS controller, created on first use. |
| 55 |
* |
| 56 |
* A variation is not a product. WooCommerce serves it from its own controller, whose |
| 57 |
* response carries `image` (singular), `wc_get_formatted_variation()` as the name, and |
| 58 |
* none of the ~25 product-only fields (`categories`, `related_ids`, `price_html`, …) |
| 59 |
* that mean nothing on a variation. 1.9.x served exactly that shape from |
| 60 |
* `API\V1\Product_Variations_Controller`; hydrating variations through the PRODUCTS |
| 61 |
* controller instead is what dropped `image` and blanked every variation thumbnail in |
| 62 |
* the POS on 1.10.0 (#1710). |
| 63 |
* |
| 64 |
* @var null|WC_REST_Product_Variations_Controller |
| 65 |
*/ |
| 66 |
private $variations_controller = null; |
| 67 |
|
| 68 |
/** |
| 69 |
* The request used when a caller does not supply one. |
| 70 |
* |
| 71 |
* @var null|WP_REST_Request |
| 72 |
*/ |
| 73 |
private $default_request = null; |
| 74 |
|
| 75 |
/** |
| 76 |
* Serialize one product or variation into its augmented REST representation. |
| 77 |
* |
| 78 |
* @param int|WC_Product $product Product id, or an already-loaded product/variation object. |
| 79 |
* @param null|WP_REST_Request $request Serialization context. A bare `GET /` request is used when omitted. |
| 80 |
* |
| 81 |
* @return array The augmented payload, or an empty array when the id does not resolve to a product. |
| 82 |
*/ |
| 83 |
public function serialize( $product, ?WP_REST_Request $request = null ): array { |
| 84 |
$object = $product instanceof WC_Product |
| 85 |
? $product |
| 86 |
: ( \function_exists( 'wc_get_product' ) ? wc_get_product( (int) $product ) : false ); |
| 87 |
|
| 88 |
if ( ! $object instanceof WC_Product ) { |
| 89 |
return array(); |
| 90 |
} |
| 91 |
|
| 92 |
$request = $request instanceof WP_REST_Request ? $request : $this->default_request(); |
| 93 |
// Every lane that hydrates a product — changes, resolve, targeted |
| 94 |
// variations, the write ack — hands this method a request it may write to |
| 95 |
// (a bare `GET /`, or a CLONE of the live request; never the dispatched |
| 96 |
// request itself, because the stamps below mutate it), so this is the ONE |
| 97 |
// place that has to carry the till's store scope into |
| 98 |
// `woocommerce_rest_prepare_product_object`. Without it the assembly line |
| 99 |
// serializes the global price and the till redisplays it moments after the |
| 100 |
// cashier changed the store's (pro#425). Stamping is idempotent and never |
| 101 |
// overrides a scope the caller set deliberately. |
| 102 |
Store_Scope::stamp( $request ); |
| 103 |
// Ours for the duration of the serialization — this runs outside any |
| 104 |
// dispatch, so the lane marker is the only signal a response filter has. |
| 105 |
$is_variation = $object instanceof WC_Product_Variation; |
| 106 |
if ( $is_variation ) { |
| 107 |
// `prepare_links()` reads `$request['product_id']` to build the nested |
| 108 |
// `products/<parent>/variations/<id>` route. No request handed here carries |
| 109 |
// it (bare, or cloned from the FLAT route), so without this the links |
| 110 |
// would claim parent 0. |
| 111 |
$request->set_param( 'product_id', $object->get_parent_id() ); |
| 112 |
} |
| 113 |
// Store scope is carried by the request + lane marker above, both controller-agnostic, |
| 114 |
// and Pro registers `bake_store_prices` on the product AND variation prepare filters — |
| 115 |
// so store-scoped prices ride either controller (pro#425). |
| 116 |
$controller = $is_variation ? $this->variations_controller() : $this->controller(); |
| 117 |
$response = Store_Scope::in_v2_lane( |
| 118 |
function () use ( $controller, $object, $request ) { |
| 119 |
return rest_ensure_response( $controller->prepare_object_for_response( $object, $request ) ); |
| 120 |
} |
| 121 |
); |
| 122 |
/** |
| 123 |
* WordPress response data is not guaranteed to be an array at runtime. |
| 124 |
* |
| 125 |
* @var mixed $payload |
| 126 |
*/ |
| 127 |
$payload = rest_get_server()->response_to_data( $response, false ); |
| 128 |
if ( $is_variation && \is_array( $payload ) ) { |
| 129 |
$payload = self::backfill_pre_wc83_variation_fields( $payload, $object ); |
| 130 |
} |
| 131 |
|
| 132 |
return self::augment( \is_array( $payload ) ? $payload : array(), $object, $request ); |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* `name` and `parent_id` on WooCommerce older than 8.3. |
| 137 |
* |
| 138 |
* WooCommerce added both to the VARIATIONS controller's response in 8.3; the products |
| 139 |
* controller has always emitted them. So moving variations onto their own controller would |
| 140 |
* silently drop two client-required fields on WooCommerce 5.3–8.2 — and this plugin still |
| 141 |
* declares `WC requires at least: 5.3`. The client reads `payload.name` for the variation row |
| 142 |
* title and `parent_id` to resolve the parent after a scan. |
| 143 |
* |
| 144 |
* The same backfill the v1 lane has always carried |
| 145 |
* (`API\V1\Product_Variations_Controller::wcpos_variation_response`), for the same reason. |
| 146 |
* |
| 147 |
* @param array $payload Serialized variation payload. |
| 148 |
* @param WC_Product_Variation $object The variation backing it. |
| 149 |
*/ |
| 150 |
private static function backfill_pre_wc83_variation_fields( array $payload, $object ): array { |
| 151 |
if ( ! isset( $payload['parent_id'] ) ) { |
| 152 |
$payload['parent_id'] = $object->get_parent_id(); |
| 153 |
} |
| 154 |
if ( ! isset( $payload['name'] ) ) { |
| 155 |
$payload['name'] = \function_exists( 'wc_get_formatted_variation' ) |
| 156 |
? wc_get_formatted_variation( $object, true, false, false ) |
| 157 |
: ''; |
| 158 |
} |
| 159 |
|
| 160 |
return $payload; |
| 161 |
} |
| 162 |
|
| 163 |
/** |
| 164 |
* Run the public augmentation filter over an already-serialized payload. |
| 165 |
* |
| 166 |
* Exposed separately for the write acknowledgement, which already holds the |
| 167 |
* bare wc/v3 data (it must hash the bare bytes for the conflict check) and only |
| 168 |
* needs the stamps applied on top. |
| 169 |
* |
| 170 |
* @param array $payload Serialized product payload. |
| 171 |
* @param mixed $object The product/variation backing the payload. |
| 172 |
* @param null|WP_REST_Request $request Serialization context. |
| 173 |
*/ |
| 174 |
public static function augment( array $payload, $object = null, ?WP_REST_Request $request = null ): array { |
| 175 |
if ( $request instanceof WP_REST_Request ) { |
| 176 |
Store_Scope::stamp( $request ); |
| 177 |
} |
| 178 |
|
| 179 |
/** |
| 180 |
* Filters a serialized WCPOS product record. |
| 181 |
* |
| 182 |
* Additive only: it must never remove WooCommerce REST fields. |
| 183 |
* |
| 184 |
* @param array $payload Serialized product payload. |
| 185 |
* @param mixed $object The product or variation backing the payload. |
| 186 |
* @param null|WP_REST_Request $request Serialization context. |
| 187 |
*/ |
| 188 |
/** |
| 189 |
* Public filters can return values outside the documented contract. |
| 190 |
* |
| 191 |
* @var mixed $augmented |
| 192 |
*/ |
| 193 |
$augmented = apply_filters( 'woocommerce_pos_sync_serialized_product', $payload, $object, $request ); |
| 194 |
|
| 195 |
return \is_array( $augmented ) ? $augmented : $payload; |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* The memoized WooCommerce products controller. |
| 200 |
*/ |
| 201 |
private function controller(): WC_REST_Products_Controller { |
| 202 |
if ( null === $this->controller ) { |
| 203 |
$this->controller = new WC_REST_Products_Controller(); |
| 204 |
} |
| 205 |
|
| 206 |
return $this->controller; |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* The memoized WooCommerce product variations controller. |
| 211 |
*/ |
| 212 |
private function variations_controller(): WC_REST_Product_Variations_Controller { |
| 213 |
if ( null === $this->variations_controller ) { |
| 214 |
$this->variations_controller = new WC_REST_Product_Variations_Controller(); |
| 215 |
} |
| 216 |
|
| 217 |
return $this->variations_controller; |
| 218 |
} |
| 219 |
|
| 220 |
/** |
| 221 |
* The memoized fallback serialization request. |
| 222 |
*/ |
| 223 |
private function default_request(): WP_REST_Request { |
| 224 |
if ( null === $this->default_request ) { |
| 225 |
$this->default_request = new WP_REST_Request( 'GET', '/' ); |
| 226 |
} |
| 227 |
|
| 228 |
return $this->default_request; |
| 229 |
} |
| 230 |
} |
| 231 |
|