PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
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.10.0, at includes/Services/Print_Job_Service.php

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