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

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