PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
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 1.9.13 All 162 releases
woocommerce-pos / includes / API / V2 / Orders_Controller.php

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

255 lines 9.7 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 WCPOS\WooCommercePOS\Sync\Api;
11 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
12 use WCPOS\WooCommercePOS\Sync\Order_Document;
13 use WCPOS\WooCommercePOS\Sync\Order_Pull_Planner;
14 use WCPOS\WooCommercePOS\Sync\Order_Query;
15 use WCPOS\WooCommercePOS\Sync\Order_Serializer;
16 use WCPOS\WooCommercePOS\Sync\Pos_Uuid;
17 use WCPOS\WooCommercePOS\Sync\Sync_Journal;
18 use WP_REST_Controller;
19 use WP_REST_Request;
20 use WP_REST_Server;
21
22 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
23
24 /**
25 * The orders custom-pull lane. Orders ride a checkpointed cursor over the
26 * order rows in the unified journal — the client greedily
27 * drains /orders/pull, coalescing each order to its net state per page (F6
28 * tombstones on the separate delete channel, F8 journal epoch/head for reset
29 * detection). Window/targeted order reads use the plain /orders wc/v3 proxy
30 * (Catalog_Proxy_Controller); this controller owns the cursor lane only.
31 *
32 * The bench-only order lanes (/orders/pull.ndjson, /orders/snapshot.ndjson,
33 * /orders/skeleton) and the sparse order_fields / compression / benchmark
34 * instrumentation stay in the lab — they never ported.
35 */
36 final class Orders_Controller extends WP_REST_Controller {
37 use Endpoint_Permissions;
38
39 public function register_routes(): void {
40 register_rest_route(
41 Api::ROUTE_NAMESPACE,
42 '/orders/pull',
43 array(
44 'methods' => WP_REST_Server::READABLE,
45 'callback' => array( $this, 'pull_orders' ),
46 'permission_callback' => array( $this, 'permissions_check' ),
47 'args' => array(
48 'limit' => array(
49 'default' => 100,
50 'sanitize_callback' => 'absint',
51 ),
52 'updated_at_gmt' => array(
53 'default' => '1970-01-01T00:00:00.000Z',
54 'sanitize_callback' => 'sanitize_text_field',
55 ),
56 'order_id' => array(
57 'default' => 0,
58 'sanitize_callback' => 'absint',
59 ),
60 'sequence' => array(
61 'default' => 0,
62 'sanitize_callback' => 'absint',
63 ),
64 'include_deletes' => array(
65 'default' => false,
66 'type' => 'boolean',
67 'sanitize_callback' => 'rest_sanitize_boolean',
68 ),
69 ),
70 )
71 );
72
73 // Out-of-band admin lane (ADR 0021): populates the gated journal that
74 // feeds /orders/pull. Zero client callers by design — an operator repair
75 // job — and it stays health-gated because its work lives in the gated
76 // table (it cannot cure a broken install, only fail against it).
77 register_rest_route(
78 Api::ROUTE_NAMESPACE,
79 '/orders/index/backfill',
80 array(
81 'methods' => WP_REST_Server::CREATABLE,
82 'callback' => array( $this, 'index_backfill' ),
83 'permission_callback' => array( $this, 'admin_permissions_check' ),
84 'args' => array(
85 'limit' => array(
86 'default' => 50,
87 'sanitize_callback' => 'absint',
88 ),
89 'reset' => array(
90 'default' => '',
91 'sanitize_callback' => 'sanitize_text_field',
92 ),
93 ),
94 )
95 );
96 }
97
98 /**
99 * The checkpointed greedy order pull. Renders the planner's typed decisions
100 * onto the batch response shape; the checkpoint hold-back, coalescing,
101 * tombstone-split and uuid-stop invariants live in the planner.
102 */
103 public function pull_orders( WP_REST_Request $request ) {
104 $limit = max( 1, min( 250, (int) $request->get_param( 'limit' ) ) );
105 $updated_at_gmt = (string) $request->get_param( 'updated_at_gmt' );
106 $order_id = (int) $request->get_param( 'order_id' );
107 $sequence = (int) $request->get_param( 'sequence' );
108 $include_deletes = rest_sanitize_boolean( $request->get_param( 'include_deletes' ) ); // strict: 'false'/'0' ⇒ false
109
110 $query = new Order_Query();
111 $serializer = new Order_Serializer();
112 $change_rows = $query->changes_after_checkpoint( $updated_at_gmt, $order_id, $sequence, $limit + 1 );
113 $has_more = count( $change_rows ) > $limit;
114 // The limit+1 probe row is a paging sentinel the planner pops before serving —
115 // it is not part of this page, so the filter must not see (or drop) its id.
116 $page_rows = $has_more ? array_slice( $change_rows, 0, $limit ) : $change_rows;
117 $ids = array_map( 'intval', array_column( $page_rows, 'order_id' ) );
118
119 /**
120 * Filters the order IDs eligible for the custom pull lane.
121 *
122 * The interim hook-parity seam for order-scoping plugins on a lane that
123 * bypasses `woocommerce_rest_orders_prepare_object_query`. It retires with
124 * the lane at the 1.11.0 protocol boundary (ADR 0035, #1748).
125 *
126 * The contract, precisely:
127 * - NARROW ONLY. Return the subset of `$ids` to serve; ids added by the
128 * filter are ignored by construction.
129 * - BE DETERMINISTIC for a given client. The checkpoint advances PAST an
130 * excluded row and its journal entry is never re-offered to that client,
131 * so exclusion is permanent per-checkpoint: a filter whose answer
132 * changes between pages corrupts what the till holds, and a scope that
133 * later WIDENS only reaches clients after a full resync.
134 * - Exclusion does not tombstone. A copy the till already holds stays
135 * until a real delete tombstones it (deleted rows bypass this filter,
136 * so tombstones for excluded orders still flow — which is the desired
137 * "drop it" signal for a scoped-out order).
138 *
139 * @since 1.10.3
140 *
141 * @param int[] $ids Candidate order IDs in this pull page.
142 * @param WP_REST_Request $request Pull request.
143 */
144 $allowed = array_map( 'intval', (array) apply_filters( 'woocommerce_pos_order_pull_ids', $ids, $request ) );
145 $allowed = array_flip( $allowed ); // O(1) membership for 250-row pages.
146
147 $planner = new Order_Pull_Planner(
148 array(
149 'updatedAtGmt' => $updated_at_gmt,
150 'orderId' => $order_id,
151 'revision' => '',
152 'sequence' => $sequence,
153 ),
154 $include_deletes
155 );
156 $plan = $planner->plan(
157 $change_rows,
158 $has_more,
159 function ( int $id ) use ( $serializer, $request, $allowed ): array {
160 // Narrow inside serialization: removing change rows would leave a
161 // fully filtered page unable to advance, so the client would loop forever.
162 if ( ! isset( $allowed[ $id ] ) ) {
163 return array();
164 }
165 $order = wc_get_order( $id );
166 $had_uuid = $order && '' !== (string) $order->get_meta( Pos_Uuid::META_KEY );
167 $payload = $serializer->serialize_order( $id, $request );
168 if ( ! $had_uuid && array() !== $payload ) {
169 /*
170 * First serialization of an unstamped order MINTS its identity: the
171 * uuid save advances the stored date_updated_gmt AFTER this payload
172 * captured the pre-mint date. Hashing that payload would serve a
173 * revision stale the moment it leaves — the client's next push
174 * false-409s against a fresh re-read. Serialize again from the
175 * settled order (a pure read now: the identity exists).
176 */
177 $payload = $serializer->serialize_order( $id, $request );
178 }
179 return $payload;
180 },
181 static function ( array $full_payload ): string {
182 return Order_Serializer::canonical_revision( $full_payload );
183 }
184 );
185
186 $documents = array();
187 $deletes = array(); // wooOrderIds of deleted orders (F6) — a SEPARATE channel from documents
188 $response_checkpoint = array(
189 'updatedAtGmt' => $updated_at_gmt,
190 'orderId' => $order_id,
191 'revision' => '',
192 'sequence' => $sequence,
193 );
194
195 foreach ( $plan as $decision ) {
196 if ( 'tombstone' === $decision['type'] ) {
197 $deletes[] = $decision['wooOrderId'];
198 continue;
199 }
200 if ( 'complete' === $decision['type'] ) {
201 $response_checkpoint = $decision['checkpoint'];
202 $has_more = $decision['hasMore'];
203 continue;
204 }
205 $full_payload = $decision['payload'];
206 $documents[] = Order_Document::build(
207 $full_payload,
208 $full_payload,
209 $decision['orderId'],
210 $decision['revision'],
211 $decision['checkpoint'],
212 false,
213 'custom-pull'
214 );
215 }
216
217 $payloads = (array) apply_filters( 'woocommerce_pos_sync_order_pull_payloads', array_column( $documents, 'payload' ), 'orders', $request );
218 foreach ( $payloads as $index => $payload ) {
219 $documents[ $index ]['payload'] = $payload;
220 }
221
222 // Journal epoch + head (F8): the client resyncs from zero when the epoch it stored differs
223 // (a new sequence generation) or when its checkpoint sequence exceeds the head (the
224 // AUTO_INCREMENT space reset beneath it). Cheap: an autoloaded option + a MAX(sequence).
225 $journal = new Sync_Journal();
226
227 return rest_ensure_response(
228 array(
229 'documents' => $documents,
230 'deletes' => $deletes, // wooOrderIds the client resolves + removes (F6); empty unless include_deletes
231 'checkpoint' => $response_checkpoint,
232 'hasMore' => $has_more,
233 'epoch' => $journal->ensure_epoch(), // F8 journal epoch — client resyncs on mismatch
234 'head' => $journal->head_sequence( array( 'order' ) ), // F8 order-lane head — client resyncs if its cursor exceeds it; stream-scoped so catalogue writes don't move it
235 'horizon' => $journal->prune_watermark( array( 'order' ) ),
236 )
237 );
238 }
239
240 /**
241 * Run one bounded chunk of the append-only journal backfill. `reset=1`
242 * clears the persisted backfill cursor so a COMPLETED store can re-run the
243 * append-only backfill; the F8 epoch is untouched.
244 */
245 public function index_backfill( WP_REST_Request $request ) {
246 $raw_limit = $request->get_param( 'limit' );
247 $limit = max( 1, min( 250, (int) ( null === $raw_limit ? 50 : $raw_limit ) ) );
248 $journal = new Sync_Journal();
249 if ( in_array( (string) $request->get_param( 'reset' ), array( '1', 'true' ), true ) ) {
250 $journal->reset_backfill_state();
251 }
252 return rest_ensure_response( $journal->run_backfill_chunk( $limit ) );
253 }
254 }
255