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 / Services / Cloud_Print_Trigger_Service.php

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

418 lines 14.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Creates cloud print jobs from WooCommerce order events.
4 *
5 * @package WCPOS\WooCommercePOS\Services
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use WCPOS\WooCommercePOS\Logger;
11
12 /**
13 * Cloud_Print_Trigger_Service class.
14 */
15 class Cloud_Print_Trigger_Service {
16 /**
17 * The Cloud Print settings option key.
18 *
19 * Read directly rather than through Cloud_Print_Section: the section's
20 * read() decorates rows with live printer status, which means outbound
21 * HTTP, and this class runs on woocommerce_new_order and
22 * woocommerce_order_status_changed. Network calls do not belong on the
23 * checkout path. It also redacts secrets, which the printer-poll and
24 * PrintNode paths need intact.
25 *
26 * @var string
27 */
28 const OPTION = 'woocommerce_pos_settings_cloud_print';
29
30 /**
31 * Cron hook used to submit a PrintNode job out-of-band (never on checkout).
32 */
33 const CRON_SUBMIT = 'wcpos_cloud_print_submit';
34
35 /**
36 * Seconds before an abandoned assignment lock may be reclaimed.
37 */
38 const ASSIGNMENT_LOCK_TTL = 120;
39
40 /**
41 * Default assignment trigger: never print before the customer has paid.
42 */
43 const DEFAULT_TRIGGER = 'paid';
44
45 /**
46 * Job store.
47 *
48 * @var Print_Job_Service
49 */
50 private $jobs;
51
52 /**
53 * Order ids whose woocommerce_payment_complete fired this request.
54 *
55 * The payment event is the authoritative "paid" signal: WCPOS routes
56 * payment_complete() to a merchant-configured per-gateway status (see
57 * Orders::payment_complete_order_status), which may not be one of
58 * wc_get_is_paid_statuses() — e.g. on-hold for account sales.
59 *
60 * @var array<int, bool>
61 */
62 private $payment_completed = array();
63
64 /**
65 * Printer registry.
66 *
67 * @var Cloud_Print_Registry
68 */
69 private $registry;
70
71 /**
72 * Constructor — hook order events.
73 */
74 public function __construct() {
75 $this->jobs = new Print_Job_Service();
76 $this->registry = new Cloud_Print_Registry();
77 add_action( 'woocommerce_new_order', array( $this, 'handle_order' ), 20, 1 );
78 add_action( 'woocommerce_order_status_changed', array( $this, 'handle_order' ), 20, 1 );
79 add_action( 'woocommerce_payment_complete', array( $this, 'handle_paid_order' ), 20, 1 );
80 }
81
82 /**
83 * Handle payment completing for an order.
84 *
85 * Runs after WC_Order::payment_complete() has moved the order to its
86 * post-payment status, which a status-changed callback may have already
87 * seen as a non-paid status. Remember the paid signal, then re-evaluate.
88 *
89 * @param int $order_id Order ID.
90 */
91 public function handle_paid_order( $order_id ): void {
92 $this->payment_completed[ (int) $order_id ] = true;
93 $this->handle_order( $order_id );
94 }
95
96 /**
97 * Normalize an assignment trigger to a supported value.
98 *
99 * Shared by the order-event path, sanitize-on-write, and normalize-on-read
100 * so the three defaulting sites cannot drift: a drifted default here would
101 * print receipts for unpaid orders.
102 *
103 * @param mixed $trigger Raw trigger value.
104 *
105 * @return string created|paid.
106 */
107 public static function normalize_trigger( $trigger ): string {
108 return \in_array( $trigger, array( 'created', 'paid' ), true ) ? $trigger : self::DEFAULT_TRIGGER;
109 }
110
111 /**
112 * Order-meta key holding how many jobs an assignment has already fired.
113 *
114 * Keyed by the same triple the job count filters on, so two rules that
115 * differ only by trigger keep separate marks. Hashed because a template id
116 * can be an arbitrary virtual slug and meta keys have a length limit.
117 *
118 * @param string $printer_id Printer id.
119 * @param string $template_id Template id.
120 * @param string $trigger Normalized trigger.
121 */
122 private static function fired_meta_key( string $printer_id, string $template_id, string $trigger ): string {
123 return '_wcpos_cp_fired_' . md5( $printer_id . "\0" . $template_id . "\0" . $trigger );
124 }
125
126 /**
127 * Create jobs for an order according to the configured assignments.
128 *
129 * @param int $order_id Order ID.
130 */
131 public function handle_order( $order_id ): void {
132 $order = wc_get_order( (int) $order_id );
133 if ( ! $order ) {
134 return;
135 }
136
137 $settings = get_option( self::OPTION, array() );
138 $assignments = isset( $settings['assignments'] ) && \is_array( $settings['assignments'] ) ? $settings['assignments'] : array();
139
140 /**
141 * Filter the cloud-print assignments for an order. Pro uses this to
142 * substitute per-outlet assignments based on the order's store.
143 *
144 * @param array $assignments Global assignments.
145 * @param \WC_Order $order The order being processed.
146 */
147 $assignments = apply_filters( 'woocommerce_pos_cloud_print_assignments', $assignments, $order );
148 if ( ! \is_array( $assignments ) ) {
149 $assignments = array();
150 }
151
152 if ( empty( $assignments ) ) {
153 return;
154 }
155
156 $is_pos = 'woocommerce-pos' === $order->get_created_via();
157
158 foreach ( $assignments as $assignment ) {
159 if ( empty( $assignment['printer_id'] ) || empty( $assignment['template_id'] ) ) {
160 continue;
161 }
162 $scope = isset( $assignment['scope'] ) ? (string) $assignment['scope'] : 'every';
163 if ( ! $this->scope_matches( $scope, $is_pos ) ) {
164 continue;
165 }
166 $trigger = self::normalize_trigger( $assignment['trigger'] ?? '' );
167 if ( ! $this->payment_state_matches( $trigger, $order ) ) {
168 continue;
169 }
170 $printer_id = (string) $assignment['printer_id'];
171 $template_id = (string) $assignment['template_id'];
172 $order_id = $order->get_id();
173 $lock = 'wcpos_cloud_print_assignment_lock_' . md5( $order_id . "\0" . $printer_id . "\0" . $template_id );
174 if ( ! $this->acquire_assignment_lock( $lock ) ) {
175 continue;
176 }
177 try {
178 // Not a duplicate of the Settings Section's clamp: this one guards
179 // the output of the woocommerce_pos_cloud_print_assignments filter,
180 // which Pro substitutes rows into (Cloud_Print_Per_Outlet). Rows
181 // that arrive through the filter never passed the section's
182 // sanitizer, so an extension can hand us copies: 999. Keep it.
183 $copies = min( 5, max( 1, (int) ( $assignment['copies'] ?? 1 ) ) );
184 // Dedupe per trigger: a created-rule job must not satisfy a
185 // paid rule for the same printer+template (and vice versa).
186 // Trigger-less jobs (manual prints, pre-trigger installs)
187 // still count toward every rule.
188 $existing = $this->jobs->count(
189 array(
190 'printer_id' => $printer_id,
191 'order_id' => $order_id,
192 'template_id' => $template_id,
193 'trigger' => $trigger,
194 )
195 );
196 // Counting rows alone cannot dedupe: the rows are deletable (by
197 // the admin, and by the retention purge), and handle_order()
198 // runs again on every later status change. A deleted receipt
199 // would then read as never printed and be queued a second time
200 // — including one the admin had deliberately cancelled. The
201 // high-water mark survives the rows it counts.
202 $fired_key = self::fired_meta_key( $printer_id, $template_id, $trigger );
203 $fired = (int) $order->get_meta( $fired_key );
204 $shortfall = max( 0, $copies - max( $existing, $fired ) );
205 if ( 0 === $shortfall ) {
206 continue;
207 }
208
209 $printer = $this->registry->get_printer( $printer_id );
210 if ( empty( $printer ) ) {
211 continue;
212 }
213 // Legacy printer rows may lack a stored provider; normalize() maps
214 // them to the star-cloudprnt default like every other read path.
215 $provider = Provider::normalize( (string) ( $printer['provider'] ?? '' ) );
216
217 $template = Print_Job_Service::load_template( $template_id );
218 if ( null === $template ) {
219 continue;
220 }
221
222 for ( $copy = 0; $copy < $shortfall; $copy++ ) {
223 $job_id = self::enqueue_order_job(
224 $this->jobs,
225 $printer_id,
226 $printer,
227 $order_id,
228 $template_id,
229 $template,
230 array(),
231 $trigger
232 );
233 if ( $job_id > 0 ) {
234 ++$fired;
235 $order->update_meta_data( $fired_key, (string) $fired );
236 $order->save_meta_data();
237 }
238 if ( 0 === $job_id ) {
239 Logger::log(
240 sprintf(
241 'Cloud print: skipping assignment for printer "%s" — template "%s" is not printable on provider "%s".',
242 $printer_id,
243 $template_id,
244 $provider
245 )
246 );
247 break;
248 }
249 }
250 } finally {
251 delete_option( $lock );
252 }
253 }
254 }
255
256 /**
257 * Acquire the lock covering copy counting and job creation.
258 *
259 * @param string $option Lock option name.
260 */
261 private function acquire_assignment_lock( string $option ): bool {
262 $now = time();
263
264 if ( add_option( $option, (string) $now, '', false ) ) {
265 return true;
266 }
267
268 $locked_at = get_option( $option, 0 );
269 if ( (int) $locked_at > 0 && ( $now - (int) $locked_at ) > self::ASSIGNMENT_LOCK_TTL ) {
270 global $wpdb;
271 // The value predicate prevents deleting a lock replaced after get_option().
272 $deleted = $wpdb->delete(
273 $wpdb->options,
274 array(
275 'option_name' => $option,
276 'option_value' => (string) $locked_at,
277 ),
278 array( '%s', '%s' )
279 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic option delete; cache cleared below.
280 if ( 1 !== $deleted ) {
281 return false;
282 }
283 wp_cache_delete( $option, 'options' );
284
285 return add_option( $option, (string) $now, '', false );
286 }
287
288 return false;
289 }
290
291 /**
292 * Enqueue a print job for an order + template, deriving the wire format from
293 * the printer's provider. Shared by the order-event trigger and the manual
294 * print-jobs endpoint so the two cannot drift.
295 *
296 * For PrintNode the job's submit event is scheduled out-of-band (PrintNode
297 * does not poll). For polling providers (Star/Epson) the printer fetches the
298 * job on its next poll, so no submit is scheduled.
299 *
300 * @param Print_Job_Service $jobs Job store.
301 * @param string $printer_id Registered printer id.
302 * @param array $printer Registered printer config.
303 * @param int $order_id Order id to render.
304 * @param string $template_id Template id (numeric) or virtual slug.
305 * @param array $template Loaded template array.
306 * @param array $drawer_options Drawer options.
307 * @param string $trigger Originating rule trigger (created|paid); empty for manual prints.
308 *
309 * @return int Created job id, or 0 when the template is not printable on the provider.
310 */
311 public static function enqueue_order_job( Print_Job_Service $jobs, string $printer_id, array $printer, int $order_id, string $template_id, array $template, array $drawer_options = array(), string $trigger = '' ): int {
312 // Normalize before EVERY consumer below (drawer options, printability,
313 // requires_submit) — a legacy row without a provider is star-cloudprnt.
314 $provider = Provider::normalize( (string) ( $printer['provider'] ?? '' ) );
315 $drawer_options = self::drawer_options_for_provider( $provider, $drawer_options );
316
317 // The resolver owns both halves of the answer for every provider: an
318 // empty kind means the template cannot be rendered on this printer.
319 $fmt = ( new Print_Format_Resolver() )->resolve( $printer, $template );
320 if ( '' === $fmt['kind'] ) {
321 return 0;
322 }
323
324 $job_args = array(
325 'printer_id' => $printer_id,
326 'content_type' => $fmt['content_type'],
327 'order_id' => $order_id,
328 'template_id' => $template_id,
329 'trigger' => $trigger,
330 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ),
331 'drawer_connector' => $drawer_options['drawer_connector'],
332 );
333 if ( Provider::stores_job_kind( $provider ) ) {
334 $job_args['pn_kind'] = $fmt['kind'];
335 }
336
337 $job_id = $jobs->create( $job_args );
338
339 // Push providers (e.g. Star Online) don't poll us; submit out-of-band.
340 if ( $job_id > 0 && Provider::requires_submit( $provider ) ) {
341 wp_schedule_single_event( time(), self::CRON_SUBMIT, array( $job_id ) );
342 }
343
344 return $job_id;
345 }
346
347 /**
348 * Keep drawer metadata scoped to providers that can act on it.
349 *
350 * Zeroing it elsewhere is not cosmetic: a job that carries drawer metadata a
351 * renderer never reads would promise the cashier a drawer kick that never
352 * fires. Star Online is the remaining opt-out — stario.online renders our
353 * markup and the markup has no drawer verb.
354 *
355 * @param string $provider Provider key.
356 * @param array $drawer_options Drawer options.
357 *
358 * @return array{auto_open_drawer:bool, drawer_connector:string}
359 */
360 private static function drawer_options_for_provider( string $provider, array $drawer_options ): array {
361 if ( ! Provider::supports_drawer( $provider ) ) {
362 return array(
363 'auto_open_drawer' => false,
364 'drawer_connector' => 'pin2',
365 );
366 }
367
368 return array(
369 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ),
370 'drawer_connector' => Print_Job_Service::normalize_drawer_connector( (string) ( $drawer_options['drawer_connector'] ?? 'pin2' ) ),
371 );
372 }
373
374 /**
375 * Whether an assignment trigger applies to this order's payment state.
376 *
377 * POS carts ARE orders from the moment the cart is saved (status
378 * pos-open), and online orders exist at checkout as pending — so
379 * 'created' fires before the customer has paid. 'paid' (the default)
380 * accepts any of three signals: a paid status per
381 * wc_get_is_paid_statuses(), the woocommerce_payment_complete event seen
382 * this request, or a stored date_paid — the latter two cover gateways
383 * whose configured post-payment status is not a WC paid status.
384 *
385 * @param string $trigger created|paid.
386 * @param \WC_Order $order The order being processed.
387 */
388 private function payment_state_matches( string $trigger, \WC_Order $order ): bool {
389 if ( 'created' === $trigger ) {
390 return true;
391 }
392
393 return $order->is_paid()
394 || ! empty( $this->payment_completed[ $order->get_id() ] )
395 || null !== $order->get_date_paid();
396 }
397
398 /**
399 * Whether an assignment scope applies to this order origin.
400 *
401 * @param string $scope every|pos|online.
402 * @param bool $is_pos Whether the order was created via the POS.
403 */
404 private function scope_matches( string $scope, bool $is_pos ): bool {
405 if ( 'every' === $scope ) {
406 return true;
407 }
408 if ( 'pos' === $scope ) {
409 return $is_pos;
410 }
411 if ( 'online' === $scope ) {
412 return ! $is_pos;
413 }
414
415 return false;
416 }
417 }
418