PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.9.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.9.16
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 / Services / Print_Job_Service.php

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

987 lines 30.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Print job store (wcpos_print_job CPT).
4 *
5 * @package WCPOS\WooCommercePOS\Services
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 /**
11 * Print_Job_Service class.
12 */
13 class Print_Job_Service {
14 const POST_TYPE = 'wcpos_print_job';
15 const META_PRINTER = '_wcpos_pj_printer_id';
16 const META_STATUS = '_wcpos_pj_status';
17 const META_CTYPE = '_wcpos_pj_content_type';
18 const META_ORDER_ID = '_wcpos_pj_order_id';
19 const META_FORMAT = '_wcpos_pj_format';
20 const META_TEMPLATE = '_wcpos_pj_template_id';
21 const META_ERROR = '_wcpos_pj_error';
22 const META_CLAIMED_AT = '_wcpos_pj_claimed_at';
23 const META_PN_KIND = '_wcpos_pj_pn_kind';
24 const META_EXTERNAL_PROVIDER = '_wcpos_pj_external_provider';
25 const META_EXTERNAL_JOB_ID = '_wcpos_pj_external_job_id';
26 const META_EXTERNAL_STATE = '_wcpos_pj_external_state';
27 const META_SUBMIT_ATTEMPTS = '_wcpos_pj_submit_attempts';
28 const META_AUTO_OPEN_DRAWER = '_wcpos_pj_auto_open_drawer';
29 const META_DRAWER_CONNECTOR = '_wcpos_pj_drawer_connector';
30 const META_DRAWER_ERROR = '_wcpos_pj_drawer_error';
31 const CLAIM_LOCK_PREFIX = 'wcpos_pj_claim_lock_';
32 const LIFECYCLE_LOCK_PREFIX = 'wcpos_pn_submit_lock_';
33 const LIFECYCLE_LOCK_TTL = 120;
34
35 /** Daily cron hook that prunes expired terminal jobs. */
36 const PURGE_HOOK = 'wcpos_print_job_purge';
37
38 /** Unix time a job reached a terminal status — the retention clock. */
39 const META_TERMINAL_AT = '_wcpos_pj_terminal_at';
40
41 /** Seconds a claimed job stays in-flight before it is treated as stale and re-queued. */
42 const CLAIM_TTL = 120;
43
44 const STATUS_PENDING = 'pending';
45 const STATUS_CLAIMED = 'claimed';
46 const STATUS_PRINTED = 'printed';
47 const STATUS_FAILED = 'failed';
48 const STATUS_CANCELLED = 'cancelled';
49
50 /**
51 * Constructor — register the CPT on init.
52 */
53 public function __construct() {
54 add_action( 'init', array( $this, 'register_post_type' ) );
55 // A static callback: several services construct Print_Job_Service on
56 // every request, and WordPress dedupes identical static callbacks, so
57 // the purge runs exactly once per cron event.
58 add_action( self::PURGE_HOOK, array( __CLASS__, 'run_purge' ) );
59 }
60
61 /**
62 * Cron entry point for the retention purge.
63 */
64 public static function run_purge(): void {
65 ( new self() )->purge_expired();
66 }
67
68 /**
69 * Register the print job post type. Internal, not publicly queryable.
70 */
71 public function register_post_type(): void {
72 register_post_type(
73 self::POST_TYPE,
74 array(
75 'label' => 'WCPOS Print Jobs',
76 'public' => false,
77 'show_ui' => false,
78 'show_in_rest' => false,
79 'exclude_from_search' => true,
80 'supports' => array( 'title', 'editor' ),
81 )
82 );
83
84 if ( ! wp_next_scheduled( self::PURGE_HOOK ) ) {
85 wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', self::PURGE_HOOK );
86 }
87 }
88
89 /**
90 * Create a print job.
91 *
92 * @param array $args printer_id (required), content_type, payload (base64), order_id, format, template_id, pn_kind.
93 *
94 * @return int Job post ID.
95 */
96 public function create( array $args ): int {
97 $id = wp_insert_post(
98 array(
99 'post_type' => self::POST_TYPE,
100 'post_status' => 'publish',
101 'post_title' => 'print-job',
102 'post_content' => isset( $args['payload'] ) ? (string) $args['payload'] : '',
103 ),
104 true
105 );
106
107 if ( is_wp_error( $id ) ) {
108 return 0;
109 }
110
111 update_post_meta( $id, self::META_PRINTER, sanitize_text_field( $args['printer_id'] ) );
112 update_post_meta( $id, self::META_STATUS, self::STATUS_PENDING );
113 update_post_meta( $id, self::META_CTYPE, sanitize_text_field( $args['content_type'] ?? 'application/octet-stream' ) );
114 if ( ! empty( $args['order_id'] ) ) {
115 update_post_meta( $id, self::META_ORDER_ID, (int) $args['order_id'] );
116 }
117 if ( ! empty( $args['format'] ) ) {
118 update_post_meta( $id, self::META_FORMAT, sanitize_text_field( $args['format'] ) );
119 }
120 if ( ! empty( $args['template_id'] ) ) {
121 update_post_meta( $id, self::META_TEMPLATE, sanitize_text_field( (string) $args['template_id'] ) );
122 }
123 if ( ! empty( $args['pn_kind'] ) ) {
124 update_post_meta( $id, self::META_PN_KIND, sanitize_text_field( (string) $args['pn_kind'] ) );
125 }
126 if ( array_key_exists( 'auto_open_drawer', $args ) ) {
127 update_post_meta( $id, self::META_AUTO_OPEN_DRAWER, ! empty( $args['auto_open_drawer'] ) ? 'yes' : 'no' );
128 }
129 if ( ! empty( $args['drawer_connector'] ) ) {
130 update_post_meta( $id, self::META_DRAWER_CONNECTOR, self::normalize_drawer_connector( (string) $args['drawer_connector'] ) );
131 }
132 do_action( 'woocommerce_pos_print_job_created', (int) $id, (string) $args['printer_id'] );
133
134 return (int) $id;
135 }
136
137 /**
138 * Get a single job as an array, or null.
139 *
140 * @param int $id Job ID.
141 *
142 * @return array|null
143 */
144 public function get( int $id ): ?array {
145 $post = get_post( $id );
146 if ( ! $post || self::POST_TYPE !== $post->post_type ) {
147 return null;
148 }
149
150 return array(
151 'id' => (int) $post->ID,
152 'created_gmt' => (string) $post->post_date_gmt,
153 'printer_id' => (string) get_post_meta( $id, self::META_PRINTER, true ),
154 'status' => (string) get_post_meta( $id, self::META_STATUS, true ),
155 'content_type' => (string) get_post_meta( $id, self::META_CTYPE, true ),
156 'order_id' => (int) get_post_meta( $id, self::META_ORDER_ID, true ),
157 'format' => (string) get_post_meta( $id, self::META_FORMAT, true ),
158 'template_id' => (string) get_post_meta( $id, self::META_TEMPLATE, true ),
159 'pn_kind' => (string) get_post_meta( $id, self::META_PN_KIND, true ),
160 'external_provider' => (string) get_post_meta( $id, self::META_EXTERNAL_PROVIDER, true ),
161 'external_job_id' => (string) get_post_meta( $id, self::META_EXTERNAL_JOB_ID, true ),
162 'external_state' => (string) get_post_meta( $id, self::META_EXTERNAL_STATE, true ),
163 'payload' => (string) $post->post_content,
164 'auto_open_drawer' => 'yes' === (string) get_post_meta( $id, self::META_AUTO_OPEN_DRAWER, true ),
165 'drawer_connector' => self::normalize_drawer_connector( (string) get_post_meta( $id, self::META_DRAWER_CONNECTOR, true ) ),
166 'drawer_error' => (string) get_post_meta( $id, self::META_DRAWER_ERROR, true ),
167 );
168 }
169
170 /**
171 * Record a successful external (push-provider) submission against a job.
172 *
173 * @param int $id Job ID.
174 * @param string $provider Provider key (e.g. 'printnode', 'star-online').
175 * @param string $job_id External job id (opaque string).
176 * @param string $state Submission state (e.g. 'submitted').
177 */
178 public function record_external_submission( int $id, string $provider, string $job_id, string $state ): void {
179 update_post_meta( $id, self::META_EXTERNAL_PROVIDER, sanitize_text_field( $provider ) );
180 update_post_meta( $id, self::META_EXTERNAL_JOB_ID, sanitize_text_field( $job_id ) );
181 update_post_meta( $id, self::META_EXTERNAL_STATE, sanitize_text_field( $state ) );
182 }
183
184 /**
185 * Normalize a cash-drawer connector identifier to the server contract.
186 *
187 * @param string $connector Incoming connector value.
188 *
189 * @return string pin2 or pin5.
190 */
191 public static function normalize_drawer_connector( string $connector ): string {
192 $connector = strtolower( trim( $connector ) );
193
194 if ( in_array( $connector, array( 'pin5', 'drawer_2', '1' ), true ) ) {
195 return 'pin5';
196 }
197
198 return 'pin2';
199 }
200
201 /**
202 * Load a receipt template by id (numeric stored template or virtual slug).
203 *
204 * Single source of truth for template resolution shared by render_payload(),
205 * the auto-print trigger, and the manual print-jobs endpoint.
206 *
207 * @param string $template_id Template id (numeric) or virtual slug.
208 *
209 * @return array|null Template array, or null when not found.
210 */
211 public static function load_template( string $template_id ): ?array {
212 return is_numeric( $template_id )
213 ? \WCPOS\WooCommercePOS\Templates::get_template( (int) $template_id )
214 : \WCPOS\WooCommercePOS\Templates::get_virtual_template( $template_id, 'receipt' );
215 }
216
217 /**
218 * Render the bytes a printer should fetch for a job.
219 *
220 * @param array $job Job array returned by get().
221 *
222 * @return string
223 */
224 public function render_payload( array $job ): string {
225 if ( ! empty( $job['order_id'] ) && ! empty( $job['template_id'] ) && ! empty( $job['pn_kind'] ) ) {
226 $template = self::load_template( (string) $job['template_id'] );
227 if ( null === $template ) {
228 return '';
229 }
230
231 $order = wc_get_order( (int) $job['order_id'] );
232 if ( ! $order ) {
233 return '';
234 }
235
236 if ( 'pdf' === $job['pn_kind'] ) {
237 try {
238 return ( new Template_Pdf_Service() )->render( $template, $order );
239 } catch ( \Throwable $e ) {
240 \WCPOS\WooCommercePOS\Logger::log(
241 sprintf( 'Cloud print: PrintNode PDF render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
242 );
243
244 return '';
245 }
246 }
247
248 if ( 'escpos' === $job['pn_kind'] ) {
249 try {
250 return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render(
251 $template,
252 $order,
253 'escpos',
254 $this->drawer_render_options( $job )
255 );
256 } catch ( \Throwable $e ) {
257 \WCPOS\WooCommercePOS\Logger::log(
258 sprintf( 'Cloud print: PrintNode ESC/POS render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
259 );
260
261 return '';
262 }
263 }
264
265 return '';
266 }
267
268 if ( ! empty( $job['order_id'] ) && ! empty( $job['template_id'] ) ) {
269 $template = self::load_template( (string) $job['template_id'] );
270 if ( null === $template ) {
271 return '';
272 }
273
274 $printer = ( new Cloud_Print_Registry() )->get_printer( (string) $job['printer_id'] );
275 $provider = $printer['provider'] ?? 'star-cloudprnt';
276 $wire = Provider::wire_format( $provider, (string) ( $template['engine'] ?? '' ) );
277 if ( null === $wire ) {
278 return '';
279 }
280
281 $order = wc_get_order( (int) $job['order_id'] );
282 if ( ! $order ) {
283 return '';
284 }
285
286 try {
287 return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render(
288 $template,
289 $order,
290 $wire,
291 $this->drawer_render_options( $job )
292 );
293 } catch ( \Throwable $e ) {
294 // Defense in depth: never let a malformed template/payload bubble up
295 // as a 500 and leave the poll's claimed job stuck. Returning empty
296 // lets the caller treat the job as having nothing to print.
297 \WCPOS\WooCommercePOS\Logger::log(
298 sprintf( 'Cloud print: thermal render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
299 );
300
301 return '';
302 }
303 }
304
305 if ( ! empty( $job['order_id'] ) && ! empty( $job['format'] ) ) {
306 $order = wc_get_order( (int) $job['order_id'] );
307 if ( ! $order ) {
308 return '';
309 }
310
311 $data = ( new Receipt_Data_Builder() )->build( $order, 'live' );
312 $adapter = ( new Receipt_Output_Adapter_Factory() )->create( (string) $job['format'] );
313
314 return $adapter->transform( $data );
315 }
316
317 $payload = base64_decode( (string) $job['payload'], true );
318
319 return false === $payload ? '' : $payload;
320 }
321
322 /**
323 * Build drawer options for thermal rendering.
324 *
325 * @param array $job Job array.
326 *
327 * @return array{auto_open_drawer:bool, drawer_connector:string}
328 */
329 private function drawer_render_options( array $job ): array {
330 return array(
331 'auto_open_drawer' => ! empty( $job['auto_open_drawer'] ),
332 'drawer_connector' => (string) ( $job['drawer_connector'] ?? 'pin2' ),
333 );
334 }
335
336 /**
337 * Query jobs by printer, status and/or order (newest first).
338 *
339 * @param array $filters printer_id, status, order_id, limit.
340 *
341 * @return array<int, array>
342 */
343 public function query( array $filters = array() ): array {
344 $meta_query = $this->filters_to_meta_query( $filters );
345
346 $posts = get_posts(
347 array(
348 'post_type' => self::POST_TYPE,
349 'post_status' => 'publish',
350 'posts_per_page' => isset( $filters['limit'] ) ? (int) $filters['limit'] : 50,
351 'paged' => isset( $filters['page'] ) ? max( 1, (int) $filters['page'] ) : 1,
352 // ID breaks date ties: jobs created in the same second must
353 // keep a stable order or offset pagination duplicates rows.
354 'orderby' => array(
355 'date' => 'ASC',
356 'ID' => 'ASC',
357 ),
358 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
359 )
360 );
361
362 return array_map(
363 function ( $post ) {
364 return $this->get( (int) $post->ID );
365 },
366 $posts
367 );
368 }
369
370 /**
371 * Queue-view rows: like query(), but never hydrates post_content — a
372 * raster receipt payload is megabytes the queue table doesn't need, and
373 * a page of them would be loaded into memory on every refresh.
374 *
375 * @param array $filters printer_id / status / limit / page.
376 *
377 * @return array<int, array>
378 */
379 public function query_rows( array $filters = array() ): array {
380 global $wpdb;
381
382 $query = new \WP_Query(
383 array(
384 'post_type' => self::POST_TYPE,
385 'post_status' => 'publish',
386 'posts_per_page' => isset( $filters['limit'] ) ? (int) $filters['limit'] : 50,
387 'paged' => isset( $filters['page'] ) ? max( 1, (int) $filters['page'] ) : 1,
388 'orderby' => array(
389 'date' => 'ASC',
390 'ID' => 'ASC',
391 ),
392 'fields' => 'ids',
393 'no_found_rows' => true,
394 'meta_query' => $this->filters_to_meta_query( $filters ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
395 )
396 );
397 $ids = array_map( 'intval', $query->posts );
398 if ( empty( $ids ) ) {
399 return array();
400 }
401 update_meta_cache( 'post', $ids );
402
403 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
404 // Direct, content-free date lookup: get_post() would pull the full
405 // row (payload included) into the object cache, defeating the point.
406 $dates = $wpdb->get_results(
407 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is a %d list.
408 $wpdb->prepare( "SELECT ID, post_date_gmt FROM {$wpdb->posts} WHERE ID IN ($placeholders)", $ids ),
409 OBJECT_K
410 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
411
412 return array_map(
413 function ( int $id ) use ( $dates ): array {
414 return array(
415 'id' => $id,
416 'created_gmt' => isset( $dates[ $id ] ) ? (string) $dates[ $id ]->post_date_gmt : '',
417 'printer_id' => (string) get_post_meta( $id, self::META_PRINTER, true ),
418 'status' => (string) get_post_meta( $id, self::META_STATUS, true ),
419 'content_type' => (string) get_post_meta( $id, self::META_CTYPE, true ),
420 'order_id' => (int) get_post_meta( $id, self::META_ORDER_ID, true ),
421 'format' => (string) get_post_meta( $id, self::META_FORMAT, true ),
422 'template_id' => (string) get_post_meta( $id, self::META_TEMPLATE, true ),
423 );
424 },
425 $ids
426 );
427 }
428
429 /**
430 * Count jobs matching the same filters query() accepts.
431 *
432 * @param array $filters printer_id / status / order_id / template_id.
433 *
434 * @return int
435 */
436 public function count( array $filters = array() ): int {
437 $query = new \WP_Query(
438 array(
439 'post_type' => self::POST_TYPE,
440 'post_status' => 'publish',
441 'posts_per_page' => 1,
442 'fields' => 'ids',
443 'meta_query' => $this->filters_to_meta_query( $filters ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
444 )
445 );
446
447 return (int) $query->found_posts;
448 }
449
450 /**
451 * One grouped pass over every job: per printer and status, the job count
452 * and the oldest creation time (GMT, MySQL format).
453 *
454 * Replaces a per-printer count/oldest query fan-out — the queue view
455 * refreshes every 30 seconds, so its summary must cost one query no
456 * matter how many printers are registered.
457 *
458 * @return array<string, array<string, array{count: int, oldest_gmt: string}>> printer_id => status => stats.
459 */
460 public function status_summary(): array {
461 global $wpdb;
462
463 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one aggregate pass; WP_Query would need 2 queries per printer.
464 $rows = $wpdb->get_results(
465 $wpdb->prepare(
466 "SELECT printer.meta_value AS printer_id, status.meta_value AS job_status,
467 COUNT(DISTINCT p.ID) AS jobs, MIN(p.post_date_gmt) AS oldest_gmt
468 FROM {$wpdb->posts} p
469 INNER JOIN {$wpdb->postmeta} printer ON printer.post_id = p.ID AND printer.meta_key = %s
470 INNER JOIN {$wpdb->postmeta} status ON status.post_id = p.ID AND status.meta_key = %s
471 WHERE p.post_type = %s AND p.post_status = 'publish'
472 GROUP BY printer.meta_value, status.meta_value",
473 self::META_PRINTER,
474 self::META_STATUS,
475 self::POST_TYPE
476 )
477 );
478
479 $summary = array();
480 foreach ( (array) $rows as $row ) {
481 $summary[ (string) $row->printer_id ][ (string) $row->job_status ] = array(
482 'count' => (int) $row->jobs,
483 'oldest_gmt' => (string) $row->oldest_gmt,
484 );
485 }
486
487 return $summary;
488 }
489
490 /**
491 * The creation time (GMT, MySQL format) of a printer's oldest waiting job.
492 *
493 * Waiting means pending or claimed: a printer that fetched a job and then
494 * died leaves it claimed forever, and that backlog must still surface.
495 *
496 * @param string $printer_id Printer id.
497 *
498 * @return string Empty when the printer has no waiting jobs.
499 */
500 public function oldest_pending_gmt( string $printer_id ): string {
501 $oldest = '';
502 foreach ( array( self::STATUS_PENDING, self::STATUS_CLAIMED ) as $status ) {
503 $rows = $this->query_rows(
504 array(
505 'printer_id' => $printer_id,
506 'status' => $status,
507 'limit' => 1,
508 )
509 );
510 if ( ! empty( $rows ) && '' !== (string) $rows[0]['created_gmt'] ) {
511 $created = (string) $rows[0]['created_gmt'];
512 if ( '' === $oldest || $created < $oldest ) {
513 $oldest = $created;
514 }
515 }
516 }
517
518 return $oldest;
519 }
520
521 /**
522 * Cancel every waiting (pending or claimed) job matching the filter.
523 *
524 * Printed, failed, and already-cancelled jobs are never touched — this
525 * exists to clear a backlog, not to rewrite history.
526 *
527 * @param array $filters ids (array of job ids) and/or printer_id.
528 *
529 * @return int Number of jobs cancelled.
530 */
531 public function cancel_waiting( array $filters ): int {
532 $cancellable = array( self::STATUS_PENDING, self::STATUS_CLAIMED );
533 $cancelled = 0;
534
535 if ( ! empty( $filters['ids'] ) ) {
536 foreach ( array_map( 'intval', (array) $filters['ids'] ) as $id ) {
537 if ( $this->cancel_if_waiting( $id ) ) {
538 ++$cancelled;
539 }
540 }
541
542 return $cancelled;
543 }
544
545 if ( empty( $filters['printer_id'] ) ) {
546 return 0;
547 }
548
549 foreach ( $cancellable as $status ) {
550 // Batched: query() pages from the front and cancelling removes
551 // jobs from the result set, so repeat until the queue is drained.
552 do {
553 $jobs = $this->query(
554 array(
555 'printer_id' => (string) $filters['printer_id'],
556 'status' => $status,
557 'limit' => 100,
558 )
559 );
560 $batch = \count( $jobs );
561 $batch_cancelled = 0;
562 foreach ( $jobs as $job ) {
563 if ( $this->cancel_if_waiting( (int) $job['id'] ) ) {
564 ++$cancelled;
565 ++$batch_cancelled;
566 }
567 }
568 } while ( 100 === $batch && $batch_cancelled > 0 );
569 }
570
571 return $cancelled;
572 }
573
574 /**
575 * Atomically cancel a waiting job while excluding provider submission.
576 *
577 * @param int $id Job ID.
578 *
579 * @return bool True when the job was cancelled.
580 */
581 public function cancel_if_waiting( int $id ): bool {
582 if ( self::POST_TYPE !== get_post_type( $id ) ) {
583 return false;
584 }
585
586 if ( ! $this->acquire_lifecycle_lock( $id ) ) {
587 return false;
588 }
589
590 try {
591 foreach ( array( self::STATUS_PENDING, self::STATUS_CLAIMED ) as $status ) {
592 if ( update_post_meta( $id, self::META_STATUS, self::STATUS_CANCELLED, $status ) ) {
593 $this->finalize_status_change( $id, self::STATUS_CANCELLED );
594
595 return true;
596 }
597 }
598
599 return false;
600 } finally {
601 $this->release_lifecycle_lock( $id );
602 }
603 }
604
605 /**
606 * A meta_query clause matching a set of job statuses.
607 *
608 * @param array<string> $statuses Status values.
609 *
610 * @return array
611 */
612 private function status_clause( array $statuses ): array {
613 return array(
614 'key' => self::META_STATUS,
615 'value' => $statuses,
616 'compare' => 'IN',
617 );
618 }
619
620 /**
621 * Translate public filters into a meta_query array.
622 *
623 * @param array $filters printer_id / status / order_id / template_id.
624 *
625 * @return array
626 */
627 private function filters_to_meta_query( array $filters ): array {
628 $meta_query = array();
629 if ( ! empty( $filters['printer_id'] ) ) {
630 $meta_query[] = array(
631 'key' => self::META_PRINTER,
632 'value' => sanitize_text_field( $filters['printer_id'] ),
633 );
634 }
635 if ( ! empty( $filters['status'] ) ) {
636 // A single status matches exactly; a list becomes an IN clause
637 // (the queue's default "active" view is pending + claimed + failed).
638 $status = \is_array( $filters['status'] )
639 ? array_map( 'sanitize_text_field', $filters['status'] )
640 : sanitize_text_field( $filters['status'] );
641 $meta_query[] = array(
642 'key' => self::META_STATUS,
643 'value' => $status,
644 'compare' => \is_array( $status ) ? 'IN' : '=',
645 );
646 }
647 if ( ! empty( $filters['order_id'] ) ) {
648 $meta_query[] = array(
649 'key' => self::META_ORDER_ID,
650 'value' => (int) $filters['order_id'],
651 'type' => 'NUMERIC',
652 );
653 }
654 if ( ! empty( $filters['template_id'] ) ) {
655 $meta_query[] = array(
656 'key' => self::META_TEMPLATE,
657 'value' => sanitize_text_field( (string) $filters['template_id'] ),
658 );
659 }
660
661 return $meta_query;
662 }
663
664 /**
665 * Set a job's status.
666 *
667 * @param int $id Job ID.
668 * @param string $status One of the STATUS_* constants.
669 */
670 public function set_status( int $id, string $status ): void {
671 update_post_meta( $id, self::META_STATUS, sanitize_text_field( $status ) );
672 $this->finalize_status_change( $id, $status );
673 }
674
675 /**
676 * Apply side effects for a status change.
677 *
678 * @param int $id Job ID.
679 * @param string $status New status.
680 */
681 private function finalize_status_change( int $id, string $status ): void {
682 if ( \in_array( $status, array( self::STATUS_PRINTED, self::STATUS_CANCELLED, self::STATUS_FAILED ), true ) ) {
683 // The retention clock starts when the job *ends*, not when it was
684 // created — a receipt that waited a week and then printed still
685 // deserves its full retention window.
686 update_post_meta( $id, self::META_TERMINAL_AT, time() );
687 }
688 if ( \in_array( $status, array( self::STATUS_PRINTED, self::STATUS_CANCELLED ), true ) ) {
689 // Terminal success (or abandonment): the payload has done its
690 // job, and a raster receipt is hundreds of KB. The row survives
691 // with metadata only — that's all the duplicate-trigger guard
692 // and the queue's history view need. Failed jobs keep their
693 // payload so Retry can copy it.
694 wp_update_post(
695 array(
696 'ID' => $id,
697 'post_content' => '',
698 )
699 );
700 }
701 }
702
703 /**
704 * Acquire the atomic per-job lifecycle lock.
705 *
706 * @param int $id Job ID.
707 *
708 * @return bool True when the lock was acquired.
709 */
710 public function acquire_lifecycle_lock( int $id ): bool {
711 $option = self::LIFECYCLE_LOCK_PREFIX . $id;
712 $now = time();
713
714 if ( add_option( $option, (string) $now, '', false ) ) {
715 return true;
716 }
717
718 $locked_at = (int) get_option( $option, 0 );
719 if ( $locked_at > 0 && ( $now - $locked_at ) > self::LIFECYCLE_LOCK_TTL ) {
720 delete_option( $option );
721
722 return add_option( $option, (string) $now, '', false );
723 }
724
725 return false;
726 }
727
728 /**
729 * Release the per-job lifecycle lock.
730 *
731 * @param int $id Job ID.
732 */
733 public function release_lifecycle_lock( int $id ): void {
734 delete_option( self::LIFECYCLE_LOCK_PREFIX . $id );
735 }
736
737 /**
738 * Delete terminal jobs past their retention window.
739 *
740 * Runs daily via PURGE_HOOK. Printed/cancelled jobs are kept for
741 * `woocommerce_pos_print_job_retention_days` (default 7 — long enough
742 * for the duplicate-trigger guard and "did it print?" questions);
743 * failed jobs for `woocommerce_pos_print_job_failed_retention_days`
744 * (default 30 — they represent unresolved problems). A filter
745 * returning 0 or less keeps that class of job forever. Waiting jobs
746 * (pending/claimed) are never purged.
747 */
748 public function purge_expired(): void {
749 $windows = array(
750 array(
751 'statuses' => array( self::STATUS_PRINTED, self::STATUS_CANCELLED ),
752 'days' => (int) apply_filters( 'woocommerce_pos_print_job_retention_days', 7 ),
753 ),
754 array(
755 'statuses' => array( self::STATUS_FAILED ),
756 'days' => (int) apply_filters( 'woocommerce_pos_print_job_failed_retention_days', 30 ),
757 ),
758 );
759
760 foreach ( $windows as $window ) {
761 if ( $window['days'] <= 0 ) {
762 continue;
763 }
764 $cutoff = time() - $window['days'] * DAY_IN_SECONDS;
765 // The retention clock is the moment the job went terminal. Rows
766 // from before this meta existed fall back to their creation date.
767 $expired_queries = array(
768 array(
769 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
770 $this->status_clause( $window['statuses'] ),
771 array(
772 'key' => self::META_TERMINAL_AT,
773 'value' => $cutoff,
774 'compare' => '<',
775 'type' => 'NUMERIC',
776 ),
777 ),
778 ),
779 array(
780 'date_query' => array(
781 array(
782 'column' => 'post_date_gmt',
783 'before' => gmdate( 'Y-m-d H:i:s', $cutoff ),
784 ),
785 ),
786 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
787 $this->status_clause( $window['statuses'] ),
788 array(
789 'key' => self::META_TERMINAL_AT,
790 'compare' => 'NOT EXISTS',
791 ),
792 ),
793 ),
794 );
795 $deleted = 0;
796 foreach ( $expired_queries as $args ) {
797 do {
798 $query = new \WP_Query(
799 array_merge(
800 array(
801 'post_type' => self::POST_TYPE,
802 'post_status' => 'publish',
803 'posts_per_page' => 200,
804 'fields' => 'ids',
805 'no_found_rows' => true,
806 ),
807 $args
808 )
809 );
810 $batch = \count( $query->posts );
811 foreach ( $query->posts as $post_id ) {
812 wp_delete_post( (int) $post_id, true );
813 ++$deleted;
814 }
815 // Bounded per run — tomorrow's cron finishes any remainder.
816 } while ( 200 === $batch && $deleted < 2000 );
817 }
818 }
819 }
820
821 /**
822 * Claim a job for printing (one in-flight job per printer).
823 *
824 * @param int $id Job ID.
825 */
826 public function claim( int $id ): void {
827 $this->try_claim( $id );
828 }
829
830 /**
831 * Attempt to claim a job while preserving one active claim per printer.
832 *
833 * @param int $id Job ID.
834 *
835 * @return bool True when the job was claimed.
836 */
837 public function try_claim( int $id ): bool {
838 $job = $this->get( $id );
839 if ( null === $job || self::STATUS_PENDING !== $job['status'] || '' === $job['printer_id'] ) {
840 return false;
841 }
842
843 $printer_id = sanitize_text_field( $job['printer_id'] );
844 if ( ! $this->acquire_claim_lock( $printer_id ) ) {
845 return false;
846 }
847
848 try {
849 if ( null !== $this->find_active_claim( $printer_id ) ) {
850 return false;
851 }
852
853 // Conditional on still-pending: a cancellation that lands between
854 // the eligibility read above and this write must win — an
855 // unconditional write would flip a just-cancelled job back to
856 // claimed and hand it to the printer.
857 if ( ! update_post_meta( $id, self::META_STATUS, self::STATUS_CLAIMED, self::STATUS_PENDING ) ) {
858 return false;
859 }
860 update_post_meta( $id, self::META_CLAIMED_AT, time() );
861
862 return true;
863 } finally {
864 $this->release_claim_lock( $printer_id );
865 }
866 }
867
868 /**
869 * The printer's current, non-stale in-flight claim, or null.
870 *
871 * @param string $printer_id Printer ID.
872 * @param int $ttl Claim TTL in seconds.
873 *
874 * @return array|null
875 */
876 public function find_active_claim( string $printer_id, int $ttl = self::CLAIM_TTL ): ?array {
877 $claimed = $this->query(
878 array(
879 'printer_id' => $printer_id,
880 'status' => self::STATUS_CLAIMED,
881 'limit' => 1,
882 )
883 );
884 if ( empty( $claimed ) ) {
885 return null;
886 }
887 $claimed_at = (int) get_post_meta( $claimed[0]['id'], self::META_CLAIMED_AT, true );
888 if ( $claimed_at > 0 && ( time() - $claimed_at ) > $ttl ) {
889 return null;
890 }
891
892 return $claimed[0];
893 }
894
895 /**
896 * Re-queue stale claims for a printer (crashed/aborted prints).
897 *
898 * @param string $printer_id Printer ID.
899 * @param int $ttl Claim TTL in seconds.
900 */
901 public function release_stale_claims( string $printer_id, int $ttl = self::CLAIM_TTL ): void {
902 $claimed = $this->query(
903 array(
904 'printer_id' => $printer_id,
905 'status' => self::STATUS_CLAIMED,
906 )
907 );
908 foreach ( $claimed as $job ) {
909 $claimed_at = (int) get_post_meta( $job['id'], self::META_CLAIMED_AT, true );
910 if ( 0 === $claimed_at || ( time() - $claimed_at ) > $ttl ) {
911 // Drop the timestamp while the job is still claimed — nothing
912 // can re-claim it until the status flips, so a fresh claim's
913 // timestamp can never be erased by this cleanup. Then the
914 // requeue is conditional on still-claimed: same race as
915 // try_claim() — a cancellation landing after the query above
916 // must not be overwritten back to pending.
917 delete_post_meta( $job['id'], self::META_CLAIMED_AT );
918 update_post_meta( $job['id'], self::META_STATUS, self::STATUS_PENDING, self::STATUS_CLAIMED );
919 }
920 }
921 }
922
923 /**
924 * Acquire a short per-printer claim lock.
925 *
926 * @param string $printer_id Printer ID.
927 *
928 * @return bool True when the lock was acquired.
929 */
930 private function acquire_claim_lock( string $printer_id ): bool {
931 $option = $this->claim_lock_option( $printer_id );
932 $now = time();
933
934 if ( add_option( $option, (string) $now, '', false ) ) {
935 return true;
936 }
937
938 $locked_at = (int) get_option( $option, 0 );
939 if ( $locked_at > 0 && ( $now - $locked_at ) > self::CLAIM_TTL ) {
940 delete_option( $option );
941
942 return add_option( $option, (string) $now, '', false );
943 }
944
945 return false;
946 }
947
948 /**
949 * Release the per-printer claim lock.
950 *
951 * @param string $printer_id Printer ID.
952 */
953 private function release_claim_lock( string $printer_id ): void {
954 delete_option( $this->claim_lock_option( $printer_id ) );
955 }
956
957 /**
958 * Build the per-printer claim lock option name.
959 *
960 * @param string $printer_id Printer ID.
961 *
962 * @return string
963 */
964 private function claim_lock_option( string $printer_id ): string {
965 return self::CLAIM_LOCK_PREFIX . md5( $printer_id );
966 }
967
968 /**
969 * The next pending job for a printer, or null.
970 *
971 * @param string $printer_id Printer ID.
972 *
973 * @return array|null
974 */
975 public function next_pending( string $printer_id ): ?array {
976 $pending = $this->query(
977 array(
978 'printer_id' => $printer_id,
979 'status' => self::STATUS_PENDING,
980 'limit' => 1,
981 )
982 );
983
984 return empty( $pending ) ? null : $pending[0];
985 }
986 }
987