PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.2
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.2
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / API / V2 / Variations_Controller.php

Variations_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.2, at includes/API/V2/Variations_Controller.php

544 lines 22.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync read surface.
4 *
5 * @package WCPOS\WooCommercePOS\API\V2
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V2;
9
10 use WC_Product_Variation;
11 use WC_REST_Product_Variations_Controller;
12 use WCPOS\WooCommercePOS\Services\Barcode_Field;
13 use WCPOS\WooCommercePOS\Sync\Api;
14 use WCPOS\WooCommercePOS\Sync\Digest_Index;
15 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
16 use WCPOS\WooCommercePOS\Sync\Pos_Visibility;
17 use WCPOS\WooCommercePOS\Sync\Product_Serializer;
18 use WP_Error;
19 use WP_Query;
20 use WP_REST_Request;
21 use WP_REST_Response;
22 use WP_REST_Server;
23
24 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
25
26 /**
27 * Variations document endpoint (on-demand variation fetch).
28 *
29 * Why a flat route: the change-signal yields BARE variation ids (no parent), and WooCommerce's
30 * only variation routes are parent-mediated (`products/<parent>/variations`). One flat route
31 * lets the client pull a deferred variation set in ONE round trip with no parent->child dance.
32 *
33 * Why it EXTENDS WooCommerce's variations controller: because that is all the route ever needed.
34 * WooCommerce's `get_objects()` already answers a cross-parent query — with no `product_id` in
35 * the route there is no parent constraint, and `include`/`search`/`orderby`/pagination are its
36 * own collection params. 1.9.x did exactly this: `parent::get_items( $request )`, one line
37 * (`API\V1\Product_Variations_Controller::wcpos_get_all_items`).
38 *
39 * The previous version of this class extended a bare `WP_REST_Controller` and rebuilt the query
40 * by hand — ~90 lines of raw postmeta SQL, five hand-declared args, no item schema — on the
41 * stated grounds that "wc/v3 has no cross-parent variations?include=". That claim was false, and
42 * the cost of acting on it was the payload: a variation hydrated through the PRODUCTS controller
43 * carries `images[]` instead of `image`, which blanked every variation thumbnail in the POS on
44 * 1.10.0 and wrote the parent's image onto every order line (#1710).
45 *
46 * What stays ours, and only this: the sync document envelope the engine reads
47 * (`documents[].{id,parent_id,payload,_rxdb_digest}`), POS visibility, the barcode carrier
48 * search, and the request bounds. Everything else is WooCommerce's.
49 */
50 class Variations_Controller extends WC_REST_Product_Variations_Controller {
51 use Endpoint_Permissions;
52
53 private const MAX_SKU_LENGTH = 4096;
54 private const MAX_SKU_TERMS = 100;
55 private const MAX_SEARCH_LENGTH = 256;
56 private const MAX_SEARCH_TERMS = 10;
57 private const MAX_PAGE = 1000;
58
59
60 public function register_routes(): void {
61 /*
62 * ONLY the flat sync route. `parent::register_routes()` is deliberately not called: the
63 * v2 namespace is a read/sync surface, and writes ride Write_Controller, which already
64 * pushes through WooCommerce's nested routes. Registering WC's CRUD routes here would
65 * widen the POS-marker-gated surface for no consumer.
66 *
67 * The args and the schema are WooCommerce's own, so `include`, `search`, `orderby`,
68 * `order`, `offset`, `page`, `per_page`, `status` … all behave exactly as they do on
69 * wc/v3, and the route documents itself in the REST index.
70 */
71 register_rest_route(
72 Api::ROUTE_NAMESPACE,
73 '/variations',
74 array(
75 array(
76 'methods' => WP_REST_Server::READABLE,
77 'callback' => array( $this, 'get_variations' ),
78 'permission_callback' => array( $this, 'permissions_check' ),
79 'args' => $this->get_collection_params(),
80 ),
81 'schema' => array( $this, 'get_public_item_schema' ),
82 )
83 );
84 }
85
86 /**
87 * Narrow WooCommerce's variation query to what the POS may serve.
88 *
89 * Everything WooCommerce already understands — `include`, `offset`, `order`, pagination,
90 * status — comes from `parent::prepare_objects_query()`. Layered on top: POS visibility, the
91 * barcode-carrier search, and the sort keys the POS grids offer. This is the seam 1.9.x used
92 * for the same job (`API\V1\Product_Variations_Controller::prepare_objects_query`).
93 *
94 * @param WP_REST_Request $request Full details about the request.
95 *
96 * @return array
97 */
98 protected function prepare_objects_query( $request ) {
99 /*
100 * WooCommerce splits `sku` on commas without trimming, so `sku=A, B` looks for " B".
101 * Normalize before it sees the param rather than reimplementing its matching.
102 */
103 $sku = (string) ( $request->get_param( 'sku' ) ?? '' );
104 if ( '' !== $sku ) {
105 $terms = array_values(
106 array_filter(
107 array_map( 'trim', explode( ',', $sku ) ),
108 static function ( string $term ): bool {
109 return '' !== $term;
110 }
111 )
112 );
113 $sku = implode( ',', $terms );
114 $request->set_param( 'sku', $sku );
115 }
116
117 $args = parent::prepare_objects_query( $request );
118
119 /*
120 * A product is not a variation document.
121 *
122 * WooCommerce widens `post_type` to `array( 'product', 'product_variation' )` whenever
123 * `sku` is set, because the two share one SKU space. On THIS route that would serve a
124 * simple product as a variation — and the client would file it into its variations
125 * collection, the mirror image of the misfiled-variation pollution it already carries a
126 * one-shot repair for. Type purity on a variations route is ours to enforce.
127 */
128 $args['post_type'] = $this->post_type;
129
130 /*
131 * `search` means the barcode CARRIERS here, not the post title.
132 *
133 * WooCommerce maps `search` onto `s`, which searches post_title/content — useless for a
134 * variation, whose title is a generated attribute string. The POS searches what a cashier
135 * actually types or scans: the SKU and whichever meta key the store configured as its
136 * barcode field (`Barcode_Field::search_keys()`). Any term matching any carrier wins,
137 * which is the semantics the previous hand-rolled SQL had and the specs pin.
138 *
139 * `sku` is left to WooCommerce: its own exact/comma-list handling is what the
140 * sku-beats-search precedence rule relies on.
141 */
142 $search = (string) ( $request->get_param( 'search' ) ?? '' );
143 if ( '' !== $sku ) {
144 // SKU is an exact lookup and outranks a fuzzy one; leaving WooCommerce's post-title
145 // `s` in place would AND the two and return nothing.
146 unset( $args['s'] );
147 }
148 if ( '' !== $search && '' === $sku ) {
149 unset( $args['s'] );
150 $args['wcpos_variation_search'] = true;
151 $carriers = array( 'relation' => 'OR' );
152 foreach ( (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ) as $term ) {
153 foreach ( Barcode_Field::search_keys() as $key ) {
154 $carriers[] = array(
155 'key' => $key,
156 'value' => $term,
157 'compare' => 'LIKE',
158 );
159 }
160 }
161 if ( 1 < \count( $carriers ) ) {
162 $args['meta_query'] = $this->add_meta_query( $args, $carriers ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
163 }
164 }
165
166 /*
167 * This route only ever offers what the store owner has for sale — on EVERY lane, including
168 * `include`.
169 *
170 * WooCommerce's Enabled checkbox on the variation metabox writes `post_status = private`
171 * when unchecked ({@see \WC_Meta_Box_Product_Data::save_variations()}), and WooCommerce
172 * honours that everywhere a customer can reach: `get_visible_children()` and
173 * `get_available_variations()` both drop it. A cashier must not be able to sell a variation
174 * the owner switched off, so the POS behaves the same way.
175 *
176 * The `include` lane is NOT exempt. Being asked for an id by name is not evidence the owner
177 * wants it sold: the client learns those ids from the parent's `variations[]`, which
178 * WooCommerce fills from `get_children()` — publish AND private — and from the change
179 * signal, which journals a disabled variation like any other post. A disabled id simply is
180 * not hydrated, the client's targeted-pull shortfall prunes it, and it leaves every till.
181 * Re-enabling saves the variation, which journals it, and it comes back.
182 *
183 * Set after `parent::prepare_objects_query()` so an explicit `status` param cannot widen it.
184 */
185 $args['post_status'] = 'publish';
186
187 /*
188 * Leg-3 (ADR 0014 WP-M5): POS-hidden (`online_only`) variations are never served. As a
189 * query exclusion rather than a post-hoc filter of the result, so paging and totals count
190 * the same set the client is allowed to see.
191 *
192 * Through the helper, NOT a raw `post__not_in` merge: `parent::prepare_objects_query()`
193 * maps `include` to `post__in`, and WP_Query IGNORES `post__not_in` when `post__in` is
194 * present — so `?search=X&include=<hidden id>` would have served a hidden variation.
195 * `apply_to_wp_query_args()` already owns that trap: it intersects `post__in` with the
196 * hidden set and pins an empty intersection to `array( 0 )`.
197 */
198 $args = ( new Pos_Visibility() )->apply_to_wp_query_args( $args, Pos_Visibility::VARIATIONS );
199
200 /*
201 * The POS sorts on fields WooCommerce does not offer as orderby values. They are declared
202 * in get_collection_params() below — without that, `orderby=sku` is rejected by REST
203 * argument validation before this switch ever runs.
204 */
205 if ( isset( $request['orderby'] ) ) {
206 switch ( $request['orderby'] ) {
207 case 'sku':
208 $args['meta_key'] = '_sku'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
209 $args['orderby'] = 'meta_value';
210
211 break;
212 case 'barcode':
213 $args['meta_key'] = Barcode_Field::orderby_key(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
214 $args['orderby'] = 'meta_value';
215
216 break;
217 case 'stock_quantity':
218 $args['meta_key'] = '_stock'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
219 $args['orderby'] = 'meta_value_num';
220
221 break;
222 case 'stock_status':
223 $args['meta_key'] = '_stock_status'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
224 $args['orderby'] = 'meta_value';
225
226 break;
227 }
228 }
229
230 return $args;
231 }
232
233 /**
234 * GET /variations?include=12,34,56 — hydrate the given variation ids.
235 *
236 * Mirrors the wc/v3 `products?include=` shape; the parent is resolved
237 * server-side off the loaded variation object (get_parent_id), so the client
238 * never needs to know parents. Unknown / non-variation ids are skipped
239 * (deletes are handled by the change-signal tombstone path, not here).
240 */
241 public function get_variations( WP_REST_Request $request ) {
242 $started = microtime( true );
243 $search_meta = null;
244 if ( $request->has_param( 'sku' ) || $request->has_param( 'search' ) ) {
245 $validation = $this->validate_search_request( $request );
246 if ( is_wp_error( $validation ) ) {
247 return $validation;
248 }
249 list( $ids, $search_meta ) = $this->search_variation_ids( $request );
250 } elseif ( array() === array_filter( (array) $request->get_param( 'include' ) ) ) {
251 /*
252 * A bare collection request — no `include`, no discovery term — answers page one of the
253 * POS-servable set with WooCommerce's own pagination, exactly as its `get_items()` would.
254 *
255 * This used to be a 400. That refusal is why the client still counts variations on the
256 * FROZEN `wcpos/v1` lane — the single remaining v1 call in the app — because the census
257 * probes a collection route and reads `X-WP-Total`, and no v2 variations route could
258 * answer "how many". Refusing the question was never a safety property: `include` is a
259 * filter, and a collection route with no filter is a collection.
260 */
261 list( $ids, $search_meta ) = $this->collection_page( $request );
262 } else {
263 $ids = array_values( array_unique( array_map( 'intval', (array) $request->get_param( 'include' ) ) ) );
264 // Leg-3 (ADR 0014 WP-M5): drop POS-hidden (`online_only`) variations from the served set. A hidden
265 // id simply isn't hydrated → the client's targeted pull returns nothing for it → Leg-3 prunes it.
266 // (Products get the equivalent exclusion via the catalog-proxy `post__not_in` filter.)
267 $ids = ( new Pos_Visibility() )->filter_visible_children( $ids );
268 }
269 _prime_post_caches( $ids, true, true );
270
271 // Hydrate through THE product assembly line (Product_Serializer), the same
272 // seam resolve/changes use (ADR 0003 — values come from the REST
273 // representation, never raw SQL). wc_get_product() returns a
274 // WC_Product_Variation for a variation id; the instanceof guard keeps a
275 // product id from being hydrated through this lane.
276 // Leg-3 (ADR 0014): attach each variation's stored 64-bit digest as `_rxdb_digest` so the client
277 // seeds its existence-reconcile manifest from this pull too (products get theirs via the proxy
278 // filter). Bulk-read once for the whole include set. A string — the digest exceeds int range.
279 // ::class, never the bare string: from inside this namespace
280 // class_exists( 'Digest_Index' ) probes the GLOBAL namespace and is
281 // forever false, so variation digests would never emit (review finding 3).
282 // Variations read the PRODUCTS id-space — one registry row owns both
283 // object types, so this lane cannot drift from the proxy lane's answer.
284 $digests = class_exists( Digest_Index::class )
285 ? ( new Digest_Index() )->read_digests( 'products', $ids )
286 : array();
287
288 $serialization_request = new WP_REST_Request( 'GET', '/' );
289 $serializer = new Product_Serializer();
290 $documents = array();
291 foreach ( $ids as $id ) {
292 $variation = wc_get_product( $id );
293 if ( ! $variation instanceof WC_Product_Variation ) {
294 continue;
295 }
296 /*
297 * DISABLED variations are never hydrated — see the `post_status` note in
298 * prepare_objects_query(). The gate lives here as well because the `include` lane does
299 * not build a WP_Query at all: it loads each id directly, so the query-level narrowing
300 * that covers the collection and discovery lanes cannot reach it.
301 *
302 * Dropping it here (rather than out of $ids) deliberately leaves `meta.requested`
303 * counting the ask: requested > returned is precisely the shortfall the client's
304 * targeted pull reads as "prune this id".
305 */
306 if ( 'publish' !== $variation->get_status() ) {
307 continue;
308 }
309 $payload = $serializer->serialize( $variation, $serialization_request );
310 $document = array(
311 'id' => $id,
312 'parent_id' => (int) $variation->get_parent_id(),
313 'payload' => $payload,
314 );
315 if ( isset( $digests[ $id ] ) ) {
316 $document['_rxdb_digest'] = $digests[ $id ];
317 }
318 $documents[] = $document;
319 }
320
321 $meta = array(
322 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
323 'requested' => \count( $ids ),
324 'returned' => \count( $documents ),
325 );
326 if ( null !== $search_meta ) {
327 $meta = array_merge( $meta, $search_meta );
328 }
329
330 $response = rest_ensure_response(
331 array(
332 'documents' => $documents,
333 'meta' => $meta,
334 )
335 );
336
337 /*
338 * The pagination WooCommerce would have sent.
339 *
340 * No v2 route emitted `X-WP-Total`/`X-WP-TotalPages` — including this one, the only one
341 * that paginates. The client asks for them on every v2 GET (the response envelope mirrors
342 * them into the body), so it has been receiving an empty mirror and falling back to
343 * short-page detection, which cannot tell "last page" from "the server truncated".
344 */
345 if ( null !== $search_meta && $response instanceof WP_REST_Response ) {
346 $response->header( 'X-WP-Total', (string) $search_meta['total'] );
347 $response->header(
348 'X-WP-TotalPages',
349 (string) ( $search_meta['per_page'] > 0 ? (int) ceil( $search_meta['total'] / $search_meta['per_page'] ) : 0 )
350 );
351 }
352
353 return $response;
354 }
355
356 /**
357 * Reject search requests that could build excessively large SQL queries or offsets.
358 *
359 * @return true|WP_Error
360 */
361 private function validate_search_request( WP_REST_Request $request ) {
362 $sku = (string) ( $request->get_param( 'sku' ) ?? '' );
363 $skus = array_filter(
364 array_map( 'trim', explode( ',', $sku ) ),
365 static function ( string $term ): bool {
366 return '' !== $term;
367 }
368 );
369 if ( array() !== $skus ) {
370 $sku = implode( ',', $skus );
371 if ( self::MAX_SKU_LENGTH < \strlen( $sku ) ) {
372 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not exceed 4096 bytes', array( 'status' => 400 ) );
373 }
374 if ( self::MAX_SKU_TERMS < \count( $skus ) ) {
375 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not contain more than 100 comma-separated terms', array( 'status' => 400 ) );
376 }
377 } else {
378 $search = (string) $request->get_param( 'search' );
379 if ( self::MAX_SEARCH_LENGTH < \strlen( $search ) ) {
380 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not exceed 256 bytes', array( 'status' => 400 ) );
381 }
382 $terms = (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY );
383 if ( self::MAX_SEARCH_TERMS < \count( $terms ) ) {
384 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not contain more than 10 whitespace-separated terms', array( 'status' => 400 ) );
385 }
386 }
387
388 if ( self::MAX_PAGE < (int) $request->get_param( 'page' ) ) {
389 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'page must not exceed 1000', array( 'status' => 400 ) );
390 }
391
392 return true;
393 }
394
395 /**
396 * WooCommerce's collection params, plus the sort keys the POS grids offer.
397 *
398 * `orderby` is a validated enum. Appending here is what lets `prepare_objects_query()` act on
399 * these four — otherwise the request 400s during argument validation and the switch is dead
400 * code. 1.9.x extended the same enum for the same reason.
401 */
402 public function get_collection_params() {
403 $params = parent::get_collection_params();
404
405 if ( isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) {
406 $params['orderby']['enum'] = array_values(
407 array_unique(
408 array_merge(
409 $params['orderby']['enum'],
410 array( 'sku', 'barcode', 'stock_quantity', 'stock_status' )
411 )
412 )
413 );
414 }
415
416 return $params;
417 }
418
419 /**
420 * De-duplicate variation searches joined through matching meta rows.
421 *
422 * @param string $groupby Existing GROUP BY clause.
423 * @param WP_Query $query Query being filtered.
424 */
425 public function group_search_results( string $groupby, WP_Query $query ): string {
426 global $wpdb;
427
428 return ! empty( $query->query_vars['wcpos_variation_search'] ) ? "{$wpdb->posts}.ID" : $groupby;
429 }
430
431 /**
432 * Does this discovery request still carry a term after normalization?
433 *
434 * `has_param()` is what selects discovery mode, and an empty or whitespace-only value passes
435 * it. This is the check that decides whether a query would actually be constrained.
436 */
437 private function has_discovery_constraint( WP_REST_Request $request ): bool {
438 $sku = (string) ( $request->get_param( 'sku' ) ?? '' );
439 if ( '' !== trim( $sku, " \t\n\r\0\x0B," ) ) {
440 return true;
441 }
442
443 $search = (string) ( $request->get_param( 'search' ) ?? '' );
444
445 return array() !== (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY );
446 }
447
448 /**
449 * Discover a page of published, POS-visible variation ids by SKU/barcode.
450 *
451 * The query is WooCommerce's — `prepare_objects_query()` + `get_objects()`, the same pair its
452 * own `get_items()` uses. This method previously hand-built the SQL: a `wp_posts`/`wp_postmeta`
453 * INNER JOIN with `LIKE` predicates assembled per (field, term) pair, a second COUNT(DISTINCT)
454 * query for the total, and the hidden-id exclusion spliced into the same placeholder list. All
455 * of it duplicated `WP_Query` — which is where such copies go wrong, quietly and later.
456 *
457 * @return array{0: array<int, int>, 1: array{total: int, page: int, per_page: int}}
458 */
459 /**
460 * One page of the POS-servable variation collection, with its total.
461 *
462 * WooCommerce's query pair, same as {@see search_variation_ids()} — the only difference is that
463 * a bare collection request carries no discovery constraint to normalize away, so the
464 * blank-scan guard that turns `?search=%20` into zero rows must NOT apply here. Visibility and
465 * `post_status` narrowing both ride `prepare_objects_query()`, so this page counts exactly what
466 * the client is allowed to receive.
467 *
468 * @return array{0: array<int, int>, 1: array{total: int, page: int, per_page: int}}
469 */
470 private function collection_page( WP_REST_Request $request ): array {
471 $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) );
472 $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
473 $request->set_param( 'per_page', $per_page );
474 $request->set_param( 'page', $page );
475
476 $results = $this->get_objects( $this->prepare_objects_query( $request ) );
477
478 $ids = array();
479 foreach ( $results['objects'] as $object ) {
480 if ( $object instanceof WC_Product_Variation ) {
481 $ids[] = $object->get_id();
482 }
483 }
484
485 return array(
486 $ids,
487 array(
488 'total' => (int) ( $results['total'] ?? \count( $ids ) ),
489 'page' => $page,
490 'per_page' => $per_page,
491 ),
492 );
493 }
494
495 private function search_variation_ids( WP_REST_Request $request ): array {
496 $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) );
497 $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
498 $request->set_param( 'per_page', $per_page );
499 $request->set_param( 'page', $page );
500
501 $query_args = $this->prepare_objects_query( $request );
502
503 /*
504 * A discovery request whose terms normalize away — `?sku=`, `?sku=,%20`, `?search=%20` —
505 * has no constraint left. Inherited, that query would return the FIRST PAGE OF EVERY
506 * VARIATION and advertise the catalogue-wide total; the replaced SQL deliberately used
507 * `1 = 0`. A blank scan must hydrate nothing, not everything.
508 */
509 if ( ! $this->has_discovery_constraint( $request ) ) {
510 return array(
511 array(),
512 array(
513 'total' => 0,
514 'page' => $page,
515 'per_page' => $per_page,
516 ),
517 );
518 }
519
520 add_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10, 2 );
521 try {
522 $results = $this->get_objects( $query_args );
523 } finally {
524 remove_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10 );
525 }
526
527 $ids = array();
528 foreach ( $results['objects'] as $object ) {
529 if ( $object instanceof WC_Product_Variation ) {
530 $ids[] = $object->get_id();
531 }
532 }
533
534 return array(
535 $ids,
536 array(
537 'total' => (int) ( $results['total'] ?? \count( $ids ) ),
538 'page' => $page,
539 'per_page' => $per_page,
540 ),
541 );
542 }
543 }
544