PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / API / V2 / Resolve_Controller.php

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

221 lines 7.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\API\V2
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V2;
9
10 use WCPOS\WooCommercePOS\Services\Barcode_Field;
11 use WCPOS\WooCommercePOS\Sync\Api;
12 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
13 use WCPOS\WooCommercePOS\Sync\Pos_Visibility;
14 use WCPOS\WooCommercePOS\Sync\Product_Serializer;
15 use WP_Error;
16 use WP_REST_Controller;
17 use WP_REST_Request;
18 use WP_REST_Server;
19
20 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
21 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- SQL allowlists are fixed class constants; values use placeholders.
22
23 /**
24 * Barcode resolve endpoint (POS scan path).
25 *
26 * Why: a cashier scanning an unknown barcode needs one round trip that
27 * answers product OR variation directly — no parent->child REST dance.
28 * Discovery is raw SQL over ids only (ADR 0003) and hookable so plugins
29 * that own barcode storage can override resolution entirely; the returned
30 * payload is hydrated through the filtered REST serialization path.
31 *
32 * Not final: the woocommerce_pos_sync_resolve_barcode_matches filter is applied
33 * through a protected seam that the unit harness subclasses (its
34 * apply_filters stub is identity).
35 */
36 class Resolve_Controller extends WP_REST_Controller {
37 use Endpoint_Permissions;
38
39 private const PRODUCT_POST_TYPES_SQL = "('product','product_variation')";
40 private const BARCODE_META_KEYS_SQL = "('_sku','_global_unique_id','_barcode')";
41
42
43 public function register_routes(): void {
44 register_rest_route(
45 Api::ROUTE_NAMESPACE,
46 '/resolve/barcode',
47 array(
48 'methods' => WP_REST_Server::READABLE,
49 'callback' => array( $this, 'resolve_barcode' ),
50 'permission_callback' => array( $this, 'permissions_check' ),
51 'args' => array(
52 'code' => array( 'sanitize_callback' => 'sanitize_text_field' ),
53 ),
54 )
55 );
56 }
57
58 /**
59 * GET /resolve/barcode?code=<string>.
60 *
61 * 200 always: not-found is a result, not an error — the POS must be able
62 * to distinguish "no such barcode" from a failed request.
63 */
64 public function resolve_barcode( WP_REST_Request $request ) {
65 $started = microtime( true );
66 $code = trim( (string) ( $request->get_param( 'code' ) ?? '' ) );
67
68 if ( '' === $code ) {
69 return new WP_Error( 'woocommerce_pos_sync_missing_code', 'Barcode resolve requires a non-empty code parameter', array( 'status' => 400 ) );
70 }
71
72 // Discovery: raw SQL finds candidate ids only (ADR 0003 — discovery
73 // only, never values). ACTIVE-FIELD-FIRST (review finding 1): a scan
74 // resolves against the merchant's configured barcode field before any
75 // hard-coded key, so a stale value left on an inactive key (e.g. a
76 // left-over `_sku`) can never beat a match on the active field. The
77 // hard-coded barcode-bearing keys are consulted ONLY as a fallback when
78 // the active field yields no match at all. GROUP BY collapses records
79 // matching on several keys within a phase.
80 $barcode_field = Barcode_Field::meta_key();
81 $matches = $this->discover_by_meta_key( $code, $barcode_field );
82 if ( array() === $matches ) {
83 $matches = $this->discover_by_fallback_keys( $code, $barcode_field );
84 }
85 $matches = $this->apply_matches_filter( $matches, $code );
86 $visibility = new Pos_Visibility();
87 $hidden_products = $visibility->hidden_ids( Pos_Visibility::PRODUCTS );
88 $hidden_variations = $visibility->hidden_ids( Pos_Visibility::VARIATIONS );
89 $matches = array_values(
90 array_filter(
91 $matches,
92 static function ( $candidate ) use ( $hidden_products, $hidden_variations ): bool {
93 $id = (int) ( $candidate['id'] ?? 0 );
94 $type = (string) ( $candidate['type'] ?? 'product' );
95 $hidden_ids = 'variation' === $type ? $hidden_variations : $hidden_products;
96
97 return ! \in_array( $id, $hidden_ids, true );
98 }
99 )
100 );
101
102 $match = null;
103 if ( array() !== $matches ) {
104 $first = $matches[0];
105 $product = wc_get_product( (int) ( $first['id'] ?? 0 ) );
106 if ( $product ) {
107 // Hydrate only the first match, and only through THE product
108 // assembly line (Product_Serializer) — raw projections are never
109 // trusted (ADR 0003).
110 $serialization_request = new WP_REST_Request( 'GET', '/' );
111 $payload = ( new Product_Serializer() )->serialize( $product, $serialization_request );
112 $type = (string) ( $first['type'] ?? 'product' );
113 $match = array(
114 'id' => (int) ( $first['id'] ?? 0 ),
115 'type' => $type,
116 'parent_id' => 'variation' === $type ? (int) $product->get_parent_id() : 0,
117 'payload' => $payload,
118 );
119 }
120 }
121
122 return rest_ensure_response(
123 array(
124 'code' => $code,
125 'found' => null !== $match,
126 'match' => $match,
127 'ambiguous' => array_values( \array_slice( $matches, 1 ) ),
128 'meta' => array(
129 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
130 'candidates' => \count( $matches ),
131 ),
132 )
133 );
134 }
135
136 /**
137 * Discover candidate {id,type} matches on a SINGLE meta key (the active
138 * barcode field). The empty-key guard is now defensive only: Barcode_Field
139 * coerces a blank setting to the default key, so an unconfigured install
140 * resolves against the GTIN first and reaches the hard-coded keys through
141 * the normal no-match fallback rather than through an empty key.
142 */
143 private function discover_by_meta_key( string $code, string $meta_key ): array {
144 if ( '' === trim( $meta_key ) ) {
145 return array();
146 }
147 global $wpdb;
148 $rows = $wpdb->get_results(
149 $wpdb->prepare(
150 "SELECT p.ID, p.post_type FROM {$wpdb->posts} p"
151 . ' INNER JOIN ' . $wpdb->postmeta . ' pm ON pm.post_id = p.ID'
152 . ' WHERE pm.meta_key = %s AND pm.meta_value = %s'
153 . ' AND p.post_type IN ' . self::PRODUCT_POST_TYPES_SQL
154 . " AND p.post_status = 'publish'"
155 . ' GROUP BY p.ID ORDER BY p.ID ASC',
156 $meta_key,
157 $code
158 ),
159 ARRAY_A
160 );
161
162 return $this->rows_to_matches( $rows );
163 }
164
165 /**
166 * Fallback discovery across the hard-coded barcode-bearing keys, EXCLUDING
167 * the active field (already tried by discover_by_meta_key). Runs only when
168 * the active field produced no match.
169 */
170 private function discover_by_fallback_keys( string $code, string $active_field ): array {
171 global $wpdb;
172 $rows = $wpdb->get_results(
173 $wpdb->prepare(
174 "SELECT p.ID, p.post_type FROM {$wpdb->posts} p"
175 . ' INNER JOIN ' . $wpdb->postmeta . ' pm ON pm.post_id = p.ID'
176 . ' WHERE pm.meta_key IN ' . self::BARCODE_META_KEYS_SQL
177 . ' AND pm.meta_key <> %s'
178 . ' AND pm.meta_value = %s'
179 . ' AND p.post_type IN ' . self::PRODUCT_POST_TYPES_SQL
180 . " AND p.post_status = 'publish'"
181 . ' GROUP BY p.ID ORDER BY p.ID ASC',
182 $active_field,
183 $code
184 ),
185 ARRAY_A
186 );
187
188 return $this->rows_to_matches( $rows );
189 }
190
191 /**
192 * Map raw {ID,post_type} discovery rows to the {id,type} match shape.
193 *
194 * @param mixed $rows
195 */
196 private function rows_to_matches( $rows ): array {
197 $rows = \is_array( $rows ) ? $rows : array();
198 $matches = array();
199 foreach ( $rows as $row ) {
200 $matches[] = array(
201 'id' => (int) $row['ID'],
202 'type' => 'product_variation' === (string) $row['post_type'] ? 'variation' : 'product',
203 );
204 }
205
206 return $matches;
207 }
208
209 /**
210 * Seam for woocommerce_pos_sync_resolve_barcode_matches: plugins that own
211 * barcode storage (ADR 0003: value-based lookups must be hookable) can
212 * replace discovery output entirely. Protected so the unit harness —
213 * whose apply_filters stub is identity — can subclass the override path.
214 */
215 protected function apply_matches_filter( array $matches, string $code ): array {
216 $filtered = apply_filters( 'woocommerce_pos_sync_resolve_barcode_matches', $matches, $code );
217
218 return \is_array( $filtered ) ? array_values( $filtered ) : array();
219 }
220 }
221