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

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

1,271 lines 41.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_UNCONFIRMED = '_wcpos_pj_unconfirmed';
23 const META_RETRIED_TO = '_wcpos_pj_retried_to';
24 const META_CLAIMED_AT = '_wcpos_pj_claimed_at';
25 const META_PN_KIND = '_wcpos_pj_pn_kind';
26 const META_EXTERNAL_PROVIDER = '_wcpos_pj_external_provider';
27 const META_EXTERNAL_JOB_ID = '_wcpos_pj_external_job_id';
28 const META_EXTERNAL_STATE = '_wcpos_pj_external_state';
29 const META_SUBMIT_ATTEMPTS = '_wcpos_pj_submit_attempts';
30 const META_AUTO_OPEN_DRAWER = '_wcpos_pj_auto_open_drawer';
31 const META_DRAWER_CONNECTOR = '_wcpos_pj_drawer_connector';
32 const META_DRAWER_ERROR = '_wcpos_pj_drawer_error';
33
34 /**
35 * The auto-print rule trigger (created|paid) that produced this job.
36 * Absent on manual prints and on jobs created before triggers existed.
37 */
38 const META_TRIGGER = '_wcpos_pj_trigger';
39 const CLAIM_LOCK_PREFIX = 'wcpos_pj_claim_lock_';
40 const LIFECYCLE_LOCK_PREFIX = 'wcpos_pn_submit_lock_';
41 const LIFECYCLE_LOCK_TTL = 120;
42
43 /** Daily cron hook that prunes expired terminal jobs. */
44 const PURGE_HOOK = 'wcpos_print_job_purge';
45
46 /** Unix time a job reached a terminal status — the retention clock. */
47 const META_TERMINAL_AT = '_wcpos_pj_terminal_at';
48
49 /**
50 * Seconds a claimed job stays in-flight before it is treated as stale. A stale
51 * claim is failed as "unconfirmed", never re-queued: the printer may have
52 * printed it and simply be unable to report yet (see find_unconfirmed()).
53 */
54 const CLAIM_TTL = 120;
55
56 /**
57 * Seconds after a claim was failed as unconfirmed during which a printer's
58 * late result is still attributed to it. Printers report within minutes of
59 * a link coming back; anything older is a stray post, not a late result.
60 */
61 const UNCONFIRMED_RESULT_WINDOW = HOUR_IN_SECONDS;
62
63 const STATUS_PENDING = 'pending';
64 const STATUS_CLAIMED = 'claimed';
65 const STATUS_PRINTED = 'printed';
66 const STATUS_FAILED = 'failed';
67 const STATUS_CANCELLED = 'cancelled';
68
69 /**
70 * Constructor — register the CPT on init, or at once when init has passed.
71 *
72 * On a storefront request this service is constructed lazily by the first
73 * order write, long after `init`; a hook added then would never fire.
74 */
75 public function __construct() {
76 if ( did_action( 'init' ) ) {
77 $this->register_post_type();
78 } else {
79 add_action( 'init', array( $this, 'register_post_type' ) );
80 }
81 // A static callback: several services construct Print_Job_Service on
82 // every request, and WordPress dedupes identical static callbacks, so
83 // the purge runs exactly once per cron event.
84 add_action( self::PURGE_HOOK, array( __CLASS__, 'run_purge' ) );
85 }
86
87 /**
88 * Cron entry point for the retention purge.
89 */
90 public static function run_purge(): void {
91 ( new self() )->purge_expired();
92 }
93
94 /**
95 * Register the print job post type. Internal, not publicly queryable.
96 */
97 public function register_post_type(): void {
98 register_post_type(
99 self::POST_TYPE,
100 array(
101 'label' => 'WCPOS Print Jobs',
102 'public' => false,
103 'show_ui' => false,
104 'show_in_rest' => false,
105 'exclude_from_search' => true,
106 'supports' => array( 'title', 'editor' ),
107 )
108 );
109
110 if ( ! wp_next_scheduled( self::PURGE_HOOK ) ) {
111 wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', self::PURGE_HOOK );
112 }
113 }
114
115 /**
116 * Create a print job.
117 *
118 * @param array $args printer_id (required), content_type, payload (base64), order_id, format, template_id, pn_kind, trigger.
119 *
120 * @return int Job post ID.
121 */
122 public function create( array $args ): int {
123 $id = wp_insert_post(
124 array(
125 'post_type' => self::POST_TYPE,
126 'post_status' => 'publish',
127 'post_title' => 'print-job',
128 'post_content' => isset( $args['payload'] ) ? (string) $args['payload'] : '',
129 ),
130 true
131 );
132
133 if ( is_wp_error( $id ) ) {
134 return 0;
135 }
136
137 update_post_meta( $id, self::META_PRINTER, sanitize_text_field( $args['printer_id'] ) );
138 update_post_meta( $id, self::META_STATUS, self::STATUS_PENDING );
139 update_post_meta( $id, self::META_CTYPE, sanitize_text_field( $args['content_type'] ?? 'application/octet-stream' ) );
140 if ( ! empty( $args['order_id'] ) ) {
141 update_post_meta( $id, self::META_ORDER_ID, (int) $args['order_id'] );
142 }
143 if ( ! empty( $args['format'] ) ) {
144 update_post_meta( $id, self::META_FORMAT, sanitize_text_field( $args['format'] ) );
145 }
146 if ( ! empty( $args['template_id'] ) ) {
147 update_post_meta( $id, self::META_TEMPLATE, sanitize_text_field( (string) $args['template_id'] ) );
148 }
149 if ( ! empty( $args['pn_kind'] ) ) {
150 update_post_meta( $id, self::META_PN_KIND, sanitize_text_field( (string) $args['pn_kind'] ) );
151 }
152 if ( ! empty( $args['trigger'] ) ) {
153 update_post_meta( $id, self::META_TRIGGER, sanitize_text_field( (string) $args['trigger'] ) );
154 }
155 if ( array_key_exists( 'auto_open_drawer', $args ) ) {
156 update_post_meta( $id, self::META_AUTO_OPEN_DRAWER, ! empty( $args['auto_open_drawer'] ) ? 'yes' : 'no' );
157 }
158 if ( ! empty( $args['drawer_connector'] ) ) {
159 update_post_meta( $id, self::META_DRAWER_CONNECTOR, self::normalize_drawer_connector( (string) $args['drawer_connector'] ) );
160 }
161 do_action( 'woocommerce_pos_print_job_created', (int) $id, (string) $args['printer_id'] );
162
163 return (int) $id;
164 }
165
166 /**
167 * Get a single job as an array, or null.
168 *
169 * @param int $id Job ID.
170 *
171 * @return array|null
172 */
173 public function get( int $id ): ?array {
174 $post = get_post( $id );
175 if ( ! $post || self::POST_TYPE !== $post->post_type ) {
176 return null;
177 }
178
179 return array(
180 'id' => (int) $post->ID,
181 'created_gmt' => (string) $post->post_date_gmt,
182 'printer_id' => (string) get_post_meta( $id, self::META_PRINTER, true ),
183 'status' => (string) get_post_meta( $id, self::META_STATUS, true ),
184 'content_type' => (string) get_post_meta( $id, self::META_CTYPE, true ),
185 'order_id' => (int) get_post_meta( $id, self::META_ORDER_ID, true ),
186 'format' => (string) get_post_meta( $id, self::META_FORMAT, true ),
187 'template_id' => (string) get_post_meta( $id, self::META_TEMPLATE, true ),
188 'pn_kind' => (string) get_post_meta( $id, self::META_PN_KIND, true ),
189 'external_provider' => (string) get_post_meta( $id, self::META_EXTERNAL_PROVIDER, true ),
190 'external_job_id' => (string) get_post_meta( $id, self::META_EXTERNAL_JOB_ID, true ),
191 'external_state' => (string) get_post_meta( $id, self::META_EXTERNAL_STATE, true ),
192 'payload' => (string) $post->post_content,
193 'auto_open_drawer' => 'yes' === (string) get_post_meta( $id, self::META_AUTO_OPEN_DRAWER, true ),
194 'drawer_connector' => self::normalize_drawer_connector( (string) get_post_meta( $id, self::META_DRAWER_CONNECTOR, true ) ),
195 'drawer_error' => (string) get_post_meta( $id, self::META_DRAWER_ERROR, true ),
196 'retried_to' => (int) get_post_meta( $id, self::META_RETRIED_TO, true ),
197 'error' => (string) get_post_meta( $id, self::META_ERROR, true ),
198 'unconfirmed' => '1' === (string) get_post_meta( $id, self::META_UNCONFIRMED, true ),
199 'terminal_at' => (int) get_post_meta( $id, self::META_TERMINAL_AT, true ),
200 );
201 }
202
203 /**
204 * Record a successful external (push-provider) submission against a job.
205 *
206 * @param int $id Job ID.
207 * @param string $provider Provider key (e.g. 'printnode', 'star-online').
208 * @param string $job_id External job id (opaque string).
209 * @param string $state Submission state (e.g. 'submitted').
210 */
211 public function record_external_submission( int $id, string $provider, string $job_id, string $state ): void {
212 update_post_meta( $id, self::META_EXTERNAL_PROVIDER, sanitize_text_field( $provider ) );
213 update_post_meta( $id, self::META_EXTERNAL_JOB_ID, sanitize_text_field( $job_id ) );
214 update_post_meta( $id, self::META_EXTERNAL_STATE, sanitize_text_field( $state ) );
215 }
216
217 /**
218 * Normalize a cash-drawer connector identifier to the server contract.
219 *
220 * @param string $connector Incoming connector value.
221 *
222 * @return string pin2 or pin5.
223 */
224 public static function normalize_drawer_connector( string $connector ): string {
225 $connector = strtolower( trim( $connector ) );
226
227 if ( in_array( $connector, array( 'pin5', 'drawer_2', '1' ), true ) ) {
228 return 'pin5';
229 }
230
231 return 'pin2';
232 }
233
234 /**
235 * Load a receipt template by id (numeric stored template or virtual slug).
236 *
237 * Single source of truth for template resolution shared by render_payload(),
238 * the auto-print trigger, and the manual print-jobs endpoint.
239 *
240 * @param string $template_id Template id (numeric) or virtual slug.
241 *
242 * @return array|null Template array, or null when not found.
243 */
244 public static function load_template( string $template_id ): ?array {
245 return is_numeric( $template_id )
246 ? \WCPOS\WooCommercePOS\Templates::get_template( (int) $template_id )
247 : \WCPOS\WooCommercePOS\Templates::get_virtual_template( $template_id, 'receipt' );
248 }
249
250 /**
251 * Render the bytes a printer should fetch for a job.
252 *
253 * @param array $job Job array returned by get().
254 * @param string $media_type Negotiated media type, when the transport chose one.
255 *
256 * @return string
257 */
258 public function render_payload( array $job, string $media_type = '' ): string {
259 return $this->render_job( $job, $media_type )['body'];
260 }
261
262 /**
263 * Render a job, reporting peripherals its payload cannot carry.
264 *
265 * `$media_type` is the format a CloudPRNT printer picked out of the poll
266 * response's offer. When it names a format the thermal pipeline can produce,
267 * it overrides the provider's default wire format — this is what makes the
268 * offer real rather than decorative. An empty string keeps the provider
269 * default, which is what every non-negotiating caller passes.
270 *
271 * The `cut` and `drawer` keys are non-null only for command-free formats,
272 * where the peripherals have to be requested out-of-band; see
273 * Thermal_Renderer::render_with_control().
274 *
275 * @param array $job Job array returned by get().
276 * @param string $media_type Negotiated media type, when the transport chose one.
277 *
278 * @return array{body:string, cut:string|null, drawer:string|null}
279 */
280 public function render_job( array $job, string $media_type = '' ): array {
281 if ( ! empty( $job['order_id'] ) && ! empty( $job['template_id'] ) && ! empty( $job['pn_kind'] ) ) {
282 $template = self::load_template( (string) $job['template_id'] );
283 if ( null === $template ) {
284 return self::nothing_to_print();
285 }
286
287 $order = wc_get_order( (int) $job['order_id'] );
288 if ( ! $order ) {
289 return self::nothing_to_print();
290 }
291
292 if ( 'pdf' === $job['pn_kind'] ) {
293 try {
294 return self::in_band( ( new Template_Pdf_Service() )->render( $template, $order ) );
295 } catch ( \Throwable $e ) {
296 \WCPOS\WooCommercePOS\Logger::log(
297 sprintf( 'Cloud print: PrintNode PDF render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
298 );
299
300 return self::nothing_to_print();
301 }
302 }
303
304 if ( 'escpos' === $job['pn_kind'] ) {
305 try {
306 return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render_with_control(
307 $template,
308 $order,
309 'escpos',
310 $this->drawer_render_options( $job )
311 );
312 } catch ( \Throwable $e ) {
313 \WCPOS\WooCommercePOS\Logger::log(
314 sprintf( 'Cloud print: PrintNode ESC/POS render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
315 );
316
317 return self::nothing_to_print();
318 }
319 }
320
321 return self::nothing_to_print();
322 }
323
324 if ( ! empty( $job['order_id'] ) && ! empty( $job['template_id'] ) ) {
325 $template = self::load_template( (string) $job['template_id'] );
326 if ( null === $template ) {
327 return self::nothing_to_print();
328 }
329
330 $printer = ( new Cloud_Print_Registry() )->get_printer( (string) $job['printer_id'] );
331 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
332 $wire = Provider::wire_format( $provider, (string) ( $template['engine'] ?? '' ) );
333 if ( null === $wire ) {
334 return self::nothing_to_print();
335 }
336
337 $negotiated = '' === $media_type ? '' : Cloud_Print_Media_Types::wire_format( $media_type );
338 if ( '' !== $negotiated ) {
339 $wire = $negotiated;
340 }
341
342 $order = wc_get_order( (int) $job['order_id'] );
343 if ( ! $order ) {
344 return self::nothing_to_print();
345 }
346
347 try {
348 return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render_with_control(
349 $template,
350 $order,
351 $wire,
352 $this->drawer_render_options( $job )
353 );
354 } catch ( \Throwable $e ) {
355 // Defense in depth: never let a malformed template/payload bubble up
356 // as a 500 and leave the poll's claimed job stuck. Returning empty
357 // lets the caller treat the job as having nothing to print.
358 \WCPOS\WooCommercePOS\Logger::log(
359 sprintf( 'Cloud print: thermal render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
360 );
361
362 return self::nothing_to_print();
363 }
364 }
365
366 if ( ! empty( $job['order_id'] ) && ! empty( $job['format'] ) ) {
367 $order = wc_get_order( (int) $job['order_id'] );
368 if ( ! $order ) {
369 return self::nothing_to_print();
370 }
371
372 try {
373 $data = ( new Receipt_Data_Builder() )->build( $order, 'live' );
374 $adapter = ( new Receipt_Output_Adapter_Factory() )->create( (string) $job['format'] );
375
376 return self::in_band( $adapter->transform( $data ) );
377 } catch ( \Throwable $e ) {
378 // A stored job can carry a format the factory no longer supports
379 // (e.g. the removed fixed-layout starprnt placeholder). Fail closed
380 // like the thermal branch above: log and print nothing rather than
381 // letting the poll 500 with a claimed job stuck.
382 \WCPOS\WooCommercePOS\Logger::log(
383 sprintf( 'Cloud print: fixed-layout render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
384 );
385
386 return self::nothing_to_print();
387 }
388 }
389
390 $payload = base64_decode( (string) $job['payload'], true );
391
392 return self::in_band( false === $payload ? '' : $payload );
393 }
394
395 /**
396 * A render result whose payload carries its own cut and drawer commands.
397 *
398 * @param string $body The rendered payload.
399 *
400 * @return array{body:string, cut:string|null, drawer:string|null}
401 */
402 private static function in_band( string $body ): array {
403 return array(
404 'body' => $body,
405 'cut' => null,
406 'drawer' => null,
407 );
408 }
409
410 /**
411 * The render result for a job that produced nothing.
412 *
413 * @return array{body:string, cut:string|null, drawer:string|null}
414 */
415 private static function nothing_to_print(): array {
416 return self::in_band( '' );
417 }
418
419 /**
420 * Build drawer options for thermal rendering.
421 *
422 * @param array $job Job array.
423 *
424 * @return array{auto_open_drawer:bool, drawer_connector:string}
425 */
426 private function drawer_render_options( array $job ): array {
427 return array(
428 'auto_open_drawer' => ! empty( $job['auto_open_drawer'] ),
429 'drawer_connector' => (string) ( $job['drawer_connector'] ?? 'pin2' ),
430 );
431 }
432
433 /**
434 * Query jobs by printer, status and/or order (oldest first).
435 *
436 * @param array $filters printer_id, status, order_id, limit.
437 *
438 * @return array<int, array>
439 */
440 public function query( array $filters = array() ): array {
441 $meta_query = $this->filters_to_meta_query( $filters );
442
443 $posts = get_posts(
444 array(
445 'post_type' => self::POST_TYPE,
446 'post_status' => 'publish',
447 'posts_per_page' => isset( $filters['limit'] ) ? (int) $filters['limit'] : 50,
448 'paged' => isset( $filters['page'] ) ? max( 1, (int) $filters['page'] ) : 1,
449 // ID breaks date ties: jobs created in the same second must
450 // keep a stable order or offset pagination duplicates rows.
451 'orderby' => array(
452 'date' => 'ASC',
453 'ID' => 'ASC',
454 ),
455 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
456 )
457 );
458
459 return array_map(
460 function ( $post ) {
461 return $this->get( (int) $post->ID );
462 },
463 $posts
464 );
465 }
466
467 /**
468 * Queue-view rows: like query(), but never hydrates post_content — a
469 * raster receipt payload is megabytes the queue table doesn't need, and
470 * a page of them would be loaded into memory on every refresh.
471 *
472 * @param array $filters printer_id / status / limit / page.
473 *
474 * @return array<int, array>
475 */
476 public function query_rows( array $filters = array() ): array {
477 global $wpdb;
478
479 $order = isset( $filters['order'] ) && 'DESC' === strtoupper( (string) $filters['order'] ) ? 'DESC' : 'ASC';
480
481 $query = new \WP_Query(
482 array(
483 'post_type' => self::POST_TYPE,
484 'post_status' => 'publish',
485 'posts_per_page' => isset( $filters['limit'] ) ? (int) $filters['limit'] : 50,
486 'paged' => isset( $filters['page'] ) ? max( 1, (int) $filters['page'] ) : 1,
487 // Oldest-first by default: oldest_pending_gmt() reads row zero to
488 // find a printer's longest-waiting job. The queue *view* asks for
489 // DESC instead, where the newest job is the one being looked for.
490 'orderby' => array(
491 'date' => $order,
492 'ID' => $order,
493 ),
494 'fields' => 'ids',
495 'no_found_rows' => true,
496 'meta_query' => $this->filters_to_meta_query( $filters ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
497 )
498 );
499 $ids = array_map( 'intval', $query->posts );
500 if ( empty( $ids ) ) {
501 return array();
502 }
503 update_meta_cache( 'post', $ids );
504
505 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
506 // Direct, content-free date lookup: get_post() would pull the full
507 // row (payload included) into the object cache, defeating the point.
508 $dates = $wpdb->get_results(
509 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is a %d list.
510 $wpdb->prepare( "SELECT ID, post_date_gmt FROM {$wpdb->posts} WHERE ID IN ($placeholders)", $ids ),
511 OBJECT_K
512 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
513
514 return array_map(
515 function ( int $id ) use ( $dates ): array {
516 return array(
517 'id' => $id,
518 'created_gmt' => isset( $dates[ $id ] ) ? (string) $dates[ $id ]->post_date_gmt : '',
519 'printer_id' => (string) get_post_meta( $id, self::META_PRINTER, true ),
520 'status' => (string) get_post_meta( $id, self::META_STATUS, true ),
521 'content_type' => (string) get_post_meta( $id, self::META_CTYPE, true ),
522 'order_id' => (int) get_post_meta( $id, self::META_ORDER_ID, true ),
523 'format' => (string) get_post_meta( $id, self::META_FORMAT, true ),
524 'template_id' => (string) get_post_meta( $id, self::META_TEMPLATE, true ),
525 'retried_to' => (int) get_post_meta( $id, self::META_RETRIED_TO, true ),
526 'error' => (string) get_post_meta( $id, self::META_ERROR, true ),
527 'unconfirmed' => '1' === (string) get_post_meta( $id, self::META_UNCONFIRMED, true ),
528 'terminal_at' => (int) get_post_meta( $id, self::META_TERMINAL_AT, true ),
529 );
530 },
531 $ids
532 );
533 }
534
535 /**
536 * Count jobs matching the same filters query() accepts.
537 *
538 * @param array $filters printer_id / status / order_id / template_id / trigger / exclude_retried.
539 *
540 * @return int
541 */
542 public function count( array $filters = array() ): int {
543 $query = new \WP_Query(
544 array(
545 'post_type' => self::POST_TYPE,
546 'post_status' => 'publish',
547 'posts_per_page' => 1,
548 'fields' => 'ids',
549 'meta_query' => $this->filters_to_meta_query( $filters ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
550 )
551 );
552
553 return (int) $query->found_posts;
554 }
555
556 /**
557 * One grouped pass over every job: per printer and status, the job count
558 * and the oldest creation time (GMT, MySQL format).
559 *
560 * Replaces a per-printer count/oldest query fan-out — the queue view
561 * refreshes every 30 seconds, so its summary must cost one query no
562 * matter how many printers are registered.
563 *
564 * @return array<string, array<string, array{count: int, unresolved_count: int, oldest_gmt: string}>> printer_id => status => stats.
565 */
566 public function status_summary(): array {
567 global $wpdb;
568
569 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one aggregate pass; WP_Query would need 2 queries per printer.
570 $rows = $wpdb->get_results(
571 $wpdb->prepare(
572 "SELECT printer.meta_value AS printer_id, status.meta_value AS job_status,
573 COUNT(DISTINCT p.ID) AS jobs,
574 COUNT(DISTINCT CASE WHEN status.meta_value = %s AND retried.post_id IS NULL THEN p.ID END) AS unresolved_jobs,
575 MIN(p.post_date_gmt) AS oldest_gmt
576 FROM {$wpdb->posts} p
577 INNER JOIN {$wpdb->postmeta} printer ON printer.post_id = p.ID AND printer.meta_key = %s
578 INNER JOIN {$wpdb->postmeta} status ON status.post_id = p.ID AND status.meta_key = %s
579 LEFT JOIN {$wpdb->postmeta} retried ON retried.post_id = p.ID AND retried.meta_key = %s
580 WHERE p.post_type = %s AND p.post_status = 'publish'
581 GROUP BY printer.meta_value, status.meta_value",
582 self::STATUS_FAILED,
583 self::META_PRINTER,
584 self::META_STATUS,
585 self::META_RETRIED_TO,
586 self::POST_TYPE
587 )
588 );
589
590 $summary = array();
591 foreach ( (array) $rows as $row ) {
592 $summary[ (string) $row->printer_id ][ (string) $row->job_status ] = array(
593 'count' => (int) $row->jobs,
594 'unresolved_count' => (int) $row->unresolved_jobs,
595 'oldest_gmt' => (string) $row->oldest_gmt,
596 );
597 }
598
599 return $summary;
600 }
601
602 /**
603 * The creation time (GMT, MySQL format) of a printer's oldest waiting job.
604 *
605 * Waiting means pending or claimed: a printer that fetched a job and then
606 * died leaves it claimed forever, and that backlog must still surface.
607 *
608 * @param string $printer_id Printer id.
609 *
610 * @return string Empty when the printer has no waiting jobs.
611 */
612 public function oldest_pending_gmt( string $printer_id ): string {
613 $oldest = '';
614 foreach ( array( self::STATUS_PENDING, self::STATUS_CLAIMED ) as $status ) {
615 $rows = $this->query_rows(
616 array(
617 'printer_id' => $printer_id,
618 'status' => $status,
619 'limit' => 1,
620 )
621 );
622 if ( ! empty( $rows ) && '' !== (string) $rows[0]['created_gmt'] ) {
623 $created = (string) $rows[0]['created_gmt'];
624 if ( '' === $oldest || $created < $oldest ) {
625 $oldest = $created;
626 }
627 }
628 }
629
630 return $oldest;
631 }
632
633 /**
634 * Cancel every waiting (pending or claimed) job matching the filter.
635 *
636 * Printed, failed, and already-cancelled jobs are never touched — this
637 * exists to clear a backlog, not to rewrite history.
638 *
639 * @param array $filters ids (array of job ids) and/or printer_id.
640 *
641 * @return int Number of jobs cancelled.
642 */
643 public function cancel_waiting( array $filters ): int {
644 $cancellable = array( self::STATUS_PENDING, self::STATUS_CLAIMED );
645 $cancelled = 0;
646
647 if ( ! empty( $filters['ids'] ) ) {
648 foreach ( array_map( 'intval', (array) $filters['ids'] ) as $id ) {
649 if ( $this->cancel_if_waiting( $id ) ) {
650 ++$cancelled;
651 }
652 }
653
654 return $cancelled;
655 }
656
657 if ( empty( $filters['printer_id'] ) ) {
658 return 0;
659 }
660
661 foreach ( $cancellable as $status ) {
662 // Batched: query() pages from the front and cancelling removes
663 // jobs from the result set, so repeat until the queue is drained.
664 do {
665 $jobs = $this->query(
666 array(
667 'printer_id' => (string) $filters['printer_id'],
668 'status' => $status,
669 'limit' => 100,
670 )
671 );
672 $batch = \count( $jobs );
673 $batch_cancelled = 0;
674 foreach ( $jobs as $job ) {
675 if ( $this->cancel_if_waiting( (int) $job['id'] ) ) {
676 ++$cancelled;
677 ++$batch_cancelled;
678 }
679 }
680 } while ( 100 === $batch && $batch_cancelled > 0 );
681 }
682
683 return $cancelled;
684 }
685
686 /**
687 * Permanently remove a job row.
688 *
689 * The retention purge clears terminal jobs on its own schedule; this is the
690 * admin's manual escape hatch for a queue full of noise they do not want to
691 * wait out. A still-waiting job is cancelled first so a printer that is
692 * mid-poll cannot claim a row that is about to vanish.
693 *
694 * @param int $id Job ID.
695 *
696 * @return bool True when the row was deleted.
697 */
698 public function delete( int $id ): bool {
699 if ( self::POST_TYPE !== get_post_type( $id ) ) {
700 return false;
701 }
702
703 $status = (string) get_post_meta( $id, self::META_STATUS, true );
704 if ( \in_array( $status, array( self::STATUS_PENDING, self::STATUS_CLAIMED ), true ) && ! $this->cancel_if_waiting( $id ) ) {
705 return false;
706 }
707
708 return (bool) wp_delete_post( $id, true );
709 }
710
711 /**
712 * Atomically cancel a waiting job while excluding provider submission.
713 *
714 * @param int $id Job ID.
715 *
716 * @return bool True when the job was cancelled.
717 */
718 public function cancel_if_waiting( int $id ): bool {
719 if ( self::POST_TYPE !== get_post_type( $id ) ) {
720 return false;
721 }
722
723 if ( ! $this->acquire_lifecycle_lock( $id ) ) {
724 return false;
725 }
726
727 try {
728 foreach ( array( self::STATUS_PENDING, self::STATUS_CLAIMED ) as $status ) {
729 if ( update_post_meta( $id, self::META_STATUS, self::STATUS_CANCELLED, $status ) ) {
730 $this->finalize_status_change( $id, self::STATUS_CANCELLED );
731
732 return true;
733 }
734 }
735
736 return false;
737 } finally {
738 $this->release_lifecycle_lock( $id );
739 }
740 }
741
742 /**
743 * A meta_query clause matching a set of job statuses.
744 *
745 * @param array<string> $statuses Status values.
746 *
747 * @return array
748 */
749 private function status_clause( array $statuses ): array {
750 return array(
751 'key' => self::META_STATUS,
752 'value' => $statuses,
753 'compare' => 'IN',
754 );
755 }
756
757 /**
758 * Translate public filters into a meta_query array.
759 *
760 * @param array $filters printer_id / status / order_id / template_id.
761 *
762 * @return array
763 */
764 private function filters_to_meta_query( array $filters ): array {
765 $meta_query = array();
766 if ( ! empty( $filters['printer_id'] ) ) {
767 // Same contract as status below: one printer matches exactly, a
768 // list becomes an IN clause. sanitize_text_field() flattens an
769 // array to '', so without this a printer_id list matched nothing.
770 $printer_id = \is_array( $filters['printer_id'] )
771 ? array_map( 'sanitize_text_field', $filters['printer_id'] )
772 : sanitize_text_field( $filters['printer_id'] );
773 $meta_query[] = array(
774 'key' => self::META_PRINTER,
775 'value' => $printer_id,
776 'compare' => \is_array( $printer_id ) ? 'IN' : '=',
777 );
778 }
779 if ( ! empty( $filters['status'] ) ) {
780 // A single status matches exactly; a list becomes an IN clause
781 // (the queue's default "active" view is pending + claimed + failed).
782 $status = \is_array( $filters['status'] )
783 ? array_map( 'sanitize_text_field', $filters['status'] )
784 : sanitize_text_field( $filters['status'] );
785 if ( ! empty( $filters['exclude_retried'] ) && \in_array( self::STATUS_FAILED, (array) $status, true ) ) {
786 $active_statuses = array_values( array_diff( (array) $status, array( self::STATUS_FAILED ) ) );
787 $status_query = array( 'relation' => 'OR' );
788 if ( ! empty( $active_statuses ) ) {
789 $status_query[] = $this->status_clause( $active_statuses );
790 }
791 $status_query[] = array(
792 'relation' => 'AND',
793 array(
794 'key' => self::META_STATUS,
795 'value' => self::STATUS_FAILED,
796 ),
797 array(
798 'key' => self::META_RETRIED_TO,
799 'compare' => 'NOT EXISTS',
800 ),
801 );
802 $meta_query[] = $status_query;
803 } else {
804 $meta_query[] = array(
805 'key' => self::META_STATUS,
806 'value' => $status,
807 'compare' => \is_array( $status ) ? 'IN' : '=',
808 );
809 }
810 }
811 if ( ! empty( $filters['order_id'] ) ) {
812 $meta_query[] = array(
813 'key' => self::META_ORDER_ID,
814 'value' => (int) $filters['order_id'],
815 'type' => 'NUMERIC',
816 );
817 }
818 if ( ! empty( $filters['template_id'] ) ) {
819 $meta_query[] = array(
820 'key' => self::META_TEMPLATE,
821 'value' => sanitize_text_field( (string) $filters['template_id'] ),
822 );
823 }
824 if ( ! empty( $filters['trigger'] ) ) {
825 // Jobs attributable to this trigger: the same recorded trigger, or
826 // no trigger at all — manual prints and pre-trigger jobs count
827 // toward every rule so they keep suppressing auto reprints.
828 $meta_query[] = array(
829 'relation' => 'OR',
830 array(
831 'key' => self::META_TRIGGER,
832 'value' => sanitize_text_field( (string) $filters['trigger'] ),
833 ),
834 array(
835 'key' => self::META_TRIGGER,
836 'compare' => 'NOT EXISTS',
837 ),
838 );
839 }
840
841 return $meta_query;
842 }
843
844 /**
845 * Set a job's status.
846 *
847 * @param int $id Job ID.
848 * @param string $status One of the STATUS_* constants.
849 */
850 public function set_status( int $id, string $status ): void {
851 update_post_meta( $id, self::META_STATUS, sanitize_text_field( $status ) );
852 $this->finalize_status_change( $id, $status );
853 }
854
855 /**
856 * Mark a source job as retried and discard its dead payload.
857 *
858 * @param int $id Source job ID.
859 * @param int $replacement_id Replacement job ID.
860 *
861 * @return bool Whether the retry was recorded.
862 */
863 public function mark_retried( int $id, int $replacement_id ): bool {
864 if ( ! update_post_meta( $id, self::META_RETRIED_TO, $replacement_id ) ) {
865 return false;
866 }
867 $this->strip_payload( $id );
868
869 return true;
870 }
871
872 /**
873 * Apply side effects for a status change.
874 *
875 * @param int $id Job ID.
876 * @param string $status New status.
877 */
878 private function finalize_status_change( int $id, string $status ): void {
879 if ( \in_array( $status, array( self::STATUS_PRINTED, self::STATUS_CANCELLED, self::STATUS_FAILED ), true ) ) {
880 // The retention clock starts when the job *ends*, not when it was
881 // created — a receipt that waited a week and then printed still
882 // deserves its full retention window.
883 update_post_meta( $id, self::META_TERMINAL_AT, time() );
884 }
885 if ( \in_array( $status, array( self::STATUS_PRINTED, self::STATUS_CANCELLED ), true ) ) {
886 // Terminal success (or abandonment): the payload has done its
887 // job, and a raster receipt is hundreds of KB. The row survives
888 // with metadata only — that's all the duplicate-trigger guard
889 // and the queue's history view need. Failed jobs keep their
890 // payload so Retry can copy it until a replacement is created.
891 $this->strip_payload( $id );
892 }
893 }
894
895 /**
896 * Strip a job's stored payload while retaining its metadata.
897 *
898 * @param int $id Job ID.
899 */
900 private function strip_payload( int $id ): void {
901 wp_update_post(
902 array(
903 'ID' => $id,
904 'post_content' => '',
905 )
906 );
907 }
908
909 /**
910 * Acquire the atomic per-job lifecycle lock.
911 *
912 * @param int $id Job ID.
913 *
914 * @return bool True when the lock was acquired.
915 */
916 public function acquire_lifecycle_lock( int $id ): bool {
917 $option = self::LIFECYCLE_LOCK_PREFIX . $id;
918 $now = time();
919
920 if ( add_option( $option, (string) $now, '', false ) ) {
921 return true;
922 }
923
924 $locked_at = (int) get_option( $option, 0 );
925 if ( $locked_at > 0 && ( $now - $locked_at ) > self::LIFECYCLE_LOCK_TTL ) {
926 delete_option( $option );
927
928 return add_option( $option, (string) $now, '', false );
929 }
930
931 return false;
932 }
933
934 /**
935 * Release the per-job lifecycle lock.
936 *
937 * @param int $id Job ID.
938 */
939 public function release_lifecycle_lock( int $id ): void {
940 delete_option( self::LIFECYCLE_LOCK_PREFIX . $id );
941 }
942
943 /**
944 * Delete terminal jobs past their retention window.
945 *
946 * Runs daily via PURGE_HOOK. Printed/cancelled jobs are kept for
947 * `woocommerce_pos_print_job_retention_days` (default 7 — long enough
948 * for the duplicate-trigger guard and "did it print?" questions);
949 * failed jobs for `woocommerce_pos_print_job_failed_retention_days`
950 * (default 30 — they represent unresolved problems). A filter
951 * returning 0 or less keeps that class of job forever. Waiting jobs
952 * (pending/claimed) are never purged.
953 */
954 public function purge_expired(): void {
955 $windows = array(
956 array(
957 'statuses' => array( self::STATUS_PRINTED, self::STATUS_CANCELLED ),
958 'days' => (int) apply_filters( 'woocommerce_pos_print_job_retention_days', 7 ),
959 ),
960 array(
961 'statuses' => array( self::STATUS_FAILED ),
962 'days' => (int) apply_filters( 'woocommerce_pos_print_job_failed_retention_days', 30 ),
963 ),
964 );
965
966 foreach ( $windows as $window ) {
967 if ( $window['days'] <= 0 ) {
968 continue;
969 }
970 $cutoff = time() - $window['days'] * DAY_IN_SECONDS;
971 // The retention clock is the moment the job went terminal. Rows
972 // from before this meta existed fall back to their creation date.
973 $expired_queries = array(
974 array(
975 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
976 $this->status_clause( $window['statuses'] ),
977 array(
978 'key' => self::META_TERMINAL_AT,
979 'value' => $cutoff,
980 'compare' => '<',
981 'type' => 'NUMERIC',
982 ),
983 ),
984 ),
985 array(
986 'date_query' => array(
987 array(
988 'column' => 'post_date_gmt',
989 'before' => gmdate( 'Y-m-d H:i:s', $cutoff ),
990 ),
991 ),
992 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
993 $this->status_clause( $window['statuses'] ),
994 array(
995 'key' => self::META_TERMINAL_AT,
996 'compare' => 'NOT EXISTS',
997 ),
998 ),
999 ),
1000 );
1001 $deleted = 0;
1002 foreach ( $expired_queries as $args ) {
1003 do {
1004 $query = new \WP_Query(
1005 array_merge(
1006 array(
1007 'post_type' => self::POST_TYPE,
1008 'post_status' => 'publish',
1009 'posts_per_page' => 200,
1010 'fields' => 'ids',
1011 'no_found_rows' => true,
1012 ),
1013 $args
1014 )
1015 );
1016 $batch = \count( $query->posts );
1017 foreach ( $query->posts as $post_id ) {
1018 wp_delete_post( (int) $post_id, true );
1019 ++$deleted;
1020 }
1021 // Bounded per run — tomorrow's cron finishes any remainder.
1022 } while ( 200 === $batch && $deleted < 2000 );
1023 }
1024 }
1025 }
1026
1027 /**
1028 * Claim a job for printing (one in-flight job per printer).
1029 *
1030 * @param int $id Job ID.
1031 */
1032 public function claim( int $id ): void {
1033 $this->try_claim( $id );
1034 }
1035
1036 /**
1037 * Attempt to claim a job while preserving one active claim per printer.
1038 *
1039 * @param int $id Job ID.
1040 *
1041 * @return bool True when the job was claimed.
1042 */
1043 public function try_claim( int $id ): bool {
1044 $job = $this->get( $id );
1045 if ( null === $job || self::STATUS_PENDING !== $job['status'] || '' === $job['printer_id'] ) {
1046 return false;
1047 }
1048
1049 $printer_id = sanitize_text_field( $job['printer_id'] );
1050 if ( ! $this->acquire_claim_lock( $printer_id ) ) {
1051 return false;
1052 }
1053
1054 try {
1055 if ( null !== $this->find_active_claim( $printer_id ) ) {
1056 return false;
1057 }
1058
1059 // Conditional on still-pending: a cancellation that lands between
1060 // the eligibility read above and this write must win — an
1061 // unconditional write would flip a just-cancelled job back to
1062 // claimed and hand it to the printer.
1063 if ( ! update_post_meta( $id, self::META_STATUS, self::STATUS_CLAIMED, self::STATUS_PENDING ) ) {
1064 return false;
1065 }
1066 update_post_meta( $id, self::META_CLAIMED_AT, time() );
1067
1068 return true;
1069 } finally {
1070 $this->release_claim_lock( $printer_id );
1071 }
1072 }
1073
1074 /**
1075 * The printer's current, non-stale in-flight claim, or null.
1076 *
1077 * @param string $printer_id Printer ID.
1078 * @param int $ttl Claim TTL in seconds.
1079 *
1080 * @return array|null
1081 */
1082 public function find_active_claim( string $printer_id, int $ttl = self::CLAIM_TTL ): ?array {
1083 $claimed = $this->query(
1084 array(
1085 'printer_id' => $printer_id,
1086 'status' => self::STATUS_CLAIMED,
1087 'limit' => 1,
1088 )
1089 );
1090 if ( empty( $claimed ) ) {
1091 return null;
1092 }
1093 $claimed_at = (int) get_post_meta( $claimed[0]['id'], self::META_CLAIMED_AT, true );
1094 if ( $claimed_at > 0 && ( time() - $claimed_at ) > $ttl ) {
1095 return null;
1096 }
1097
1098 return $claimed[0];
1099 }
1100
1101 /**
1102 * The printer's newest unconfirmed, unresolved job within
1103 * UNCONFIRMED_RESULT_WINDOW, or null.
1104 *
1105 * @param string $printer_id Printer ID.
1106 *
1107 * @return array|null
1108 */
1109 public function find_unconfirmed( string $printer_id ): ?array {
1110 $posts = get_posts(
1111 array(
1112 'post_type' => self::POST_TYPE,
1113 'post_status' => 'publish',
1114 'posts_per_page' => 1,
1115 // Newest by the time it actually failed, not by creation: two jobs for
1116 // one printer can go terminal in a different order than they were
1117 // queued, and it is the most recently failed one a late result
1118 // belongs to.
1119 'orderby' => array(
1120 'terminal_at' => 'DESC',
1121 'ID' => 'DESC',
1122 ),
1123 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1124 array(
1125 'key' => self::META_PRINTER,
1126 'value' => sanitize_text_field( $printer_id ),
1127 ),
1128 array(
1129 'key' => self::META_STATUS,
1130 'value' => self::STATUS_FAILED,
1131 ),
1132 array(
1133 'key' => self::META_UNCONFIRMED,
1134 'value' => '1',
1135 ),
1136 array(
1137 'key' => self::META_RETRIED_TO,
1138 'compare' => 'NOT EXISTS',
1139 ),
1140 'terminal_at' => array(
1141 'key' => self::META_TERMINAL_AT,
1142 'value' => time() - self::UNCONFIRMED_RESULT_WINDOW,
1143 'compare' => '>=',
1144 'type' => 'NUMERIC',
1145 ),
1146 ),
1147 )
1148 );
1149 return empty( $posts ) ? null : $this->get( (int) $posts[0]->ID );
1150 }
1151
1152 /**
1153 * Record the printer's own result for a job — the claim it holds, or one
1154 * failed as unconfirmed whose result arrived late. Success clears the
1155 * unconfirmed flag and its explanatory text; a failure's code is recorded by
1156 * the caller after this.
1157 *
1158 * @param int $id Job ID.
1159 * @param bool $ok Whether the printer reported success.
1160 */
1161 public function record_printer_result( int $id, bool $ok ): void {
1162 $this->set_status( $id, $ok ? self::STATUS_PRINTED : self::STATUS_FAILED );
1163 delete_post_meta( $id, self::META_UNCONFIRMED );
1164 delete_post_meta( $id, self::META_ERROR );
1165 }
1166
1167 /**
1168 * Fail stale claims without risking an automatic duplicate print.
1169 *
1170 * @param string $printer_id Printer ID.
1171 * @param int $ttl Claim TTL in seconds.
1172 */
1173 public function release_stale_claims( string $printer_id, int $ttl = self::CLAIM_TTL ): void {
1174 $claimed = $this->query(
1175 array(
1176 'printer_id' => $printer_id,
1177 'status' => self::STATUS_CLAIMED,
1178 )
1179 );
1180 foreach ( $claimed as $job ) {
1181 $claimed_at = (int) get_post_meta( $job['id'], self::META_CLAIMED_AT, true );
1182 if ( 0 === $claimed_at || ( time() - $claimed_at ) > $ttl ) {
1183 // Drop the timestamp while the job is still claimed — nothing
1184 // can re-claim it until the status flips, so a fresh claim's
1185 // timestamp can never be erased by this cleanup. Then the
1186 // failure is conditional on still-claimed: same race as
1187 // try_claim() — a cancellation landing after the query above
1188 // must not be overwritten as failed.
1189 delete_post_meta( $job['id'], self::META_CLAIMED_AT );
1190 if ( update_post_meta( $job['id'], self::META_STATUS, self::STATUS_FAILED, self::STATUS_CLAIMED ) ) {
1191 // A machine code, like every other META_ERROR writer; the queue UI
1192 // turns the unconfirmed flag into the merchant-facing explanation.
1193 update_post_meta( $job['id'], self::META_ERROR, 'claim_timeout' );
1194 // The compare-and-swap above is the only status write; a second,
1195 // unconditional one would clobber a result or cancellation that
1196 // landed in between. Only the terminal side effects are wanted.
1197 $this->finalize_status_change( (int) $job['id'], self::STATUS_FAILED );
1198 // Flag last: it is what makes the row visible to find_unconfirmed(),
1199 // so nothing above can race a late result that lands once it is set.
1200 update_post_meta( $job['id'], self::META_UNCONFIRMED, '1' );
1201 \WCPOS\WooCommercePOS\Logger::warning( sprintf( 'Printer "%s" did not report a result for print job %d before the claim timeout.', $printer_id, (int) $job['id'] ) );
1202 }
1203 }
1204 }
1205 }
1206
1207 /**
1208 * Acquire a short per-printer claim lock.
1209 *
1210 * @param string $printer_id Printer ID.
1211 *
1212 * @return bool True when the lock was acquired.
1213 */
1214 private function acquire_claim_lock( string $printer_id ): bool {
1215 $option = $this->claim_lock_option( $printer_id );
1216 $now = time();
1217
1218 if ( add_option( $option, (string) $now, '', false ) ) {
1219 return true;
1220 }
1221
1222 $locked_at = (int) get_option( $option, 0 );
1223 if ( $locked_at > 0 && ( $now - $locked_at ) > self::CLAIM_TTL ) {
1224 delete_option( $option );
1225
1226 return add_option( $option, (string) $now, '', false );
1227 }
1228
1229 return false;
1230 }
1231
1232 /**
1233 * Release the per-printer claim lock.
1234 *
1235 * @param string $printer_id Printer ID.
1236 */
1237 private function release_claim_lock( string $printer_id ): void {
1238 delete_option( $this->claim_lock_option( $printer_id ) );
1239 }
1240
1241 /**
1242 * Build the per-printer claim lock option name.
1243 *
1244 * @param string $printer_id Printer ID.
1245 *
1246 * @return string
1247 */
1248 private function claim_lock_option( string $printer_id ): string {
1249 return self::CLAIM_LOCK_PREFIX . md5( $printer_id );
1250 }
1251
1252 /**
1253 * The next pending job for a printer, or null.
1254 *
1255 * @param string $printer_id Printer ID.
1256 *
1257 * @return array|null
1258 */
1259 public function next_pending( string $printer_id ): ?array {
1260 $pending = $this->query(
1261 array(
1262 'printer_id' => $printer_id,
1263 'status' => self::STATUS_PENDING,
1264 'limit' => 1,
1265 )
1266 );
1267
1268 return empty( $pending ) ? null : $pending[0];
1269 }
1270 }
1271