PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
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 / Sync / Order_Pull_Planner.php

Order_Pull_Planner.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.16, at includes/Sync/Order_Pull_Planner.php

171 lines 6.8 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\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use Generator;
11
12 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
13
14 /**
15 * THE orders pull planner (#424): change rows in → typed decisions out. The
16 * checkpoint bookkeeping invariants live here once, directly unit-testable
17 * without REST dispatch:
18 *
19 * - CHECKPOINT HOLD-BACK: the response checkpoint starts at the request's
20 * checkpoint and only advances when a document is actually emitted, or an
21 * order is PERMANENTLY skipped (superseded row, deleted order, payload no
22 * longer serializes). It never advances past an order held back for a
23 * transient reason — that order would be silently lost.
24 * - LATEST-SEQUENCE COALESCING: the sync-index is append-only, so one page
25 * can carry an update + a later delete (or a delete + a restore) for the
26 * same order. Superseded rows are skipped (advancing past them) so each
27 * order surfaces exactly once, as its net state at the checkpoint.
28 * - TOMBSTONE SPLIT: a deleted order (sync-index deleted=1) is never emitted
29 * as a document — checked BEFORE serializing, because a *trashed* order
30 * still loads via wc_get_order and would otherwise upsert as a stale
31 * order. When the client opted in it surfaces on the separate delete
32 * channel (F6); the checkpoint always advances past it.
33 * - UUID STOP: an empty uuid on a serialized payload is a contract
34 * violation (Order_Document's single fail-closed reaction). The planner
35 * stops the page WITHOUT advancing — merely skipping would let a LATER
36 * order's emit advance the checkpoint past the unemitted one, losing it —
37 * and raises hasMore so the client retries from the last emitted position.
38 *
39 * Pure: no REST, no WordPress state. Serialization and the fallback revision
40 * are injected callables so the invariants unit-test against plain arrays.
41 */
42 final class Order_Pull_Planner {
43 /** @var array{updatedAtGmt: string, orderId: int, revision: string, sequence: int} */
44 private array $request_checkpoint;
45 private bool $include_deletes;
46
47 public function __construct( array $request_checkpoint, bool $include_deletes ) {
48 $this->request_checkpoint = array(
49 'updatedAtGmt' => (string) ( $request_checkpoint['updatedAtGmt'] ?? '1970-01-01T00:00:00.000Z' ),
50 'orderId' => (int) ( $request_checkpoint['orderId'] ?? 0 ),
51 'revision' => (string) ( $request_checkpoint['revision'] ?? '' ),
52 'sequence' => (int) ( $request_checkpoint['sequence'] ?? 0 ),
53 );
54 $this->include_deletes = $include_deletes;
55 }
56
57 /**
58 * Plan a pull page. Yields decisions IN ORDER:
59 *
60 * - array{type:'document', orderId:int, payload:array, revision:string, checkpoint:array, sequence:int}
61 * - array{type:'tombstone', wooOrderId:int, checkpoint:array} (only when the client opted into deletes)
62 * - array{type:'complete', checkpoint:array, hasMore:bool} (EXACTLY ONE, always LAST)
63 *
64 * @param array $change_rows The sync-index page including its optional limit+1 probe row.
65 * @param bool $page_full The limit+1 probe overflowed (more rows exist beyond this page).
66 * @param callable $serialize fn(int $order_id): array — the FULL payload, or array() when the
67 * order no longer serializes (absent/inaccessible).
68 * @param callable $fallback_revision fn(array $full_payload, int $order_id, int $sequence): string —
69 * the canonical revision for fresh index rows, which normally carry none.
70 */
71 public function plan( array $change_rows, bool $page_full, callable $serialize, callable $fallback_revision ): Generator {
72 $has_more = $page_full;
73 $response_checkpoint = $this->request_checkpoint;
74 $latest_sequence_by_order = self::latest_sequence_by_order( $change_rows );
75 if ( $page_full ) {
76 array_pop( $change_rows );
77 }
78
79 foreach ( $change_rows as $change_row ) {
80 $id = (int) $change_row['order_id'];
81 $row_sequence = (int) $change_row['sequence'];
82 $row_revision = ! empty( $change_row['revision'] ) ? (string) $change_row['revision'] : '';
83 $row_modified = ! empty( $change_row['modified_gmt'] ) ? (string) $change_row['modified_gmt'] : gmdate( 'c' );
84 $checkpoint = array(
85 'updatedAtGmt' => $row_modified,
86 'orderId' => $id,
87 'revision' => $row_revision,
88 'sequence' => $row_sequence,
89 );
90
91 // Superseded within this page — skip; the order surfaces once at its latest row.
92 $latest_sequence = $latest_sequence_by_order[ $id ] ?? $row_sequence;
93 if ( $latest_sequence !== $row_sequence ) {
94 $response_checkpoint = $checkpoint;
95 continue;
96 }
97
98 // Deleted — tombstone channel (when opted in), never a document; always advance.
99 if ( ! empty( $change_row['deleted'] ) ) {
100 if ( $this->include_deletes ) {
101 yield array(
102 'type' => 'tombstone',
103 'wooOrderId' => $id,
104 'checkpoint' => $checkpoint,
105 );
106 }
107 $response_checkpoint = $checkpoint;
108 continue;
109 }
110
111 $payload = $serialize( $id );
112 if ( empty( $payload ) ) {
113 // Non-deleted but absent/inaccessible — permanently skip; advance past it.
114 $response_checkpoint = $checkpoint;
115 continue;
116 }
117
118 $revision = '' !== $row_revision ? $row_revision : (string) $fallback_revision( $payload, $id, $row_sequence );
119 $modified = isset( $payload['date_modified_gmt'] ) ? (string) $payload['date_modified_gmt'] : $row_modified;
120 $checkpoint = array(
121 'updatedAtGmt' => $modified,
122 'orderId' => $id,
123 'revision' => $revision,
124 'sequence' => $row_sequence,
125 );
126
127 try {
128 Order_Document::require_uuid( $payload, $id );
129 } catch ( Order_Uuid_Exception $exception ) {
130 // UUID STOP: end the page WITHOUT advancing; hasMore retries from
131 // the last emitted checkpoint.
132 $has_more = true;
133 break;
134 }
135
136 yield array(
137 'type' => 'document',
138 'orderId' => $id,
139 'payload' => $payload,
140 'revision' => $revision,
141 'checkpoint' => $checkpoint,
142 'sequence' => $row_sequence,
143 );
144 $response_checkpoint = $checkpoint; // emitted — safe to advance the client past this order
145 }
146
147 yield array(
148 'type' => 'complete',
149 'checkpoint' => $response_checkpoint,
150 'hasMore' => $has_more,
151 );
152 }
153
154 /**
155 * The highest sync-index sequence per order_id within a single pull page —
156 * the coalescing table. Fallback rows carry sequence 0 and are always
157 * distinct per order, so this is a no-op there.
158 */
159 public static function latest_sequence_by_order( array $change_rows ): array {
160 $latest = array();
161 foreach ( $change_rows as $change_row ) {
162 $row_order_id = (int) $change_row['order_id'];
163 $row_sequence = (int) $change_row['sequence'];
164 if ( $row_sequence > ( $latest[ $row_order_id ] ?? 0 ) ) {
165 $latest[ $row_order_id ] = $row_sequence;
166 }
167 }
168 return $latest;
169 }
170 }
171