PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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
← All changes | includes/Services/Print_Job_Service.php +334 -50 1.9.161.10.19 View file →
@@ -18,8 +18,10 @@
18 18 const META_ORDER_ID = '_wcpos_pj_order_id';
19 19 const META_FORMAT = '_wcpos_pj_format';
20 20 const META_TEMPLATE = '_wcpos_pj_template_id';
21 21 const META_ERROR = '_wcpos_pj_error';
22 + const META_UNCONFIRMED = '_wcpos_pj_unconfirmed';
23 + const META_RETRIED_TO = '_wcpos_pj_retried_to';
22 24 const META_CLAIMED_AT = '_wcpos_pj_claimed_at';
23 25 const META_PN_KIND = '_wcpos_pj_pn_kind';
24 26 const META_EXTERNAL_PROVIDER = '_wcpos_pj_external_provider';
25 27 const META_EXTERNAL_JOB_ID = '_wcpos_pj_external_job_id';
@@ -27,8 +29,14 @@
27 29 const META_SUBMIT_ATTEMPTS = '_wcpos_pj_submit_attempts';
28 30 const META_AUTO_OPEN_DRAWER = '_wcpos_pj_auto_open_drawer';
29 31 const META_DRAWER_CONNECTOR = '_wcpos_pj_drawer_connector';
30 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';
31 39 const CLAIM_LOCK_PREFIX = 'wcpos_pj_claim_lock_';
32 40 const LIFECYCLE_LOCK_PREFIX = 'wcpos_pn_submit_lock_';
33 41 const LIFECYCLE_LOCK_TTL = 120;
34 42
@@ -37,11 +45,22 @@
37 45
38 46 /** Unix time a job reached a terminal status — the retention clock. */
39 47 const META_TERMINAL_AT = '_wcpos_pj_terminal_at';
40 48
41 - /** Seconds a claimed job stays in-flight before it is treated as stale and re-queued. */
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 + */
42 54 const CLAIM_TTL = 120;
43 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 +
44 63 const STATUS_PENDING = 'pending';
45 64 const STATUS_CLAIMED = 'claimed';
46 65 const STATUS_PRINTED = 'printed';
47 66 const STATUS_FAILED = 'failed';
@@ -47,12 +66,19 @@
47 66 const STATUS_FAILED = 'failed';
48 67 const STATUS_CANCELLED = 'cancelled';
49 68
50 69 /**
51 - * Constructor — register the CPT on init.
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.
52 74 */
53 75 public function __construct() {
54 - add_action( 'init', array( $this, 'register_post_type' ) );
76 + if ( did_action( 'init' ) ) {
77 + $this->register_post_type();
78 + } else {
79 + add_action( 'init', array( $this, 'register_post_type' ) );
80 + }
55 81 // A static callback: several services construct Print_Job_Service on
56 82 // every request, and WordPress dedupes identical static callbacks, so
57 83 // the purge runs exactly once per cron event.
58 84 add_action( self::PURGE_HOOK, array( __CLASS__, 'run_purge' ) );
@@ -88,9 +114,9 @@
88 114
89 115 /**
90 116 * Create a print job.
91 117 *
92 - * @param array $args printer_id (required), content_type, payload (base64), order_id, format, template_id, pn_kind.
118 + * @param array $args printer_id (required), content_type, payload (base64), order_id, format, template_id, pn_kind, trigger.
93 119 *
94 120 * @return int Job post ID.
95 121 */
96 122 public function create( array $args ): int {
@@ -122,8 +148,11 @@
122 148 }
123 149 if ( ! empty( $args['pn_kind'] ) ) {
124 150 update_post_meta( $id, self::META_PN_KIND, sanitize_text_field( (string) $args['pn_kind'] ) );
125 151 }
152 + if ( ! empty( $args['trigger'] ) ) {
153 + update_post_meta( $id, self::META_TRIGGER, sanitize_text_field( (string) $args['trigger'] ) );
154 + }
126 155 if ( array_key_exists( 'auto_open_drawer', $args ) ) {
127 156 update_post_meta( $id, self::META_AUTO_OPEN_DRAWER, ! empty( $args['auto_open_drawer'] ) ? 'yes' : 'no' );
128 157 }
129 158 if ( ! empty( $args['drawer_connector'] ) ) {
@@ -163,8 +192,12 @@
163 192 'payload' => (string) $post->post_content,
164 193 'auto_open_drawer' => 'yes' === (string) get_post_meta( $id, self::META_AUTO_OPEN_DRAWER, true ),
165 194 'drawer_connector' => self::normalize_drawer_connector( (string) get_post_meta( $id, self::META_DRAWER_CONNECTOR, true ) ),
166 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 ),
167 200 );
168 201 }
169 202
170 203 /**
@@ -216,39 +249,62 @@
216 249
217 250 /**
218 251 * Render the bytes a printer should fetch for a job.
219 252 *
220 - * @param array $job Job array returned by get().
253 + * @param array $job Job array returned by get().
254 + * @param string $media_type Negotiated media type, when the transport chose one.
221 255 *
222 256 * @return string
223 257 */
224 - public function render_payload( array $job ): string {
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 {
225 281 if ( ! empty( $job['order_id'] ) && ! empty( $job['template_id'] ) && ! empty( $job['pn_kind'] ) ) {
226 282 $template = self::load_template( (string) $job['template_id'] );
227 283 if ( null === $template ) {
228 - return '';
284 + return self::nothing_to_print();
229 285 }
230 286
231 287 $order = wc_get_order( (int) $job['order_id'] );
232 288 if ( ! $order ) {
233 - return '';
289 + return self::nothing_to_print();
234 290 }
235 291
236 292 if ( 'pdf' === $job['pn_kind'] ) {
237 293 try {
238 - return ( new Template_Pdf_Service() )->render( $template, $order );
294 + return self::in_band( ( new Template_Pdf_Service() )->render( $template, $order ) );
239 295 } catch ( \Throwable $e ) {
240 296 \WCPOS\WooCommercePOS\Logger::log(
241 297 sprintf( 'Cloud print: PrintNode PDF render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
242 298 );
243 299
244 - return '';
300 + return self::nothing_to_print();
245 301 }
246 302 }
247 303
248 304 if ( 'escpos' === $job['pn_kind'] ) {
249 305 try {
250 - return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render(
306 + return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render_with_control(
251 307 $template,
252 308 $order,
253 309 'escpos',
254 310 $this->drawer_render_options( $job )
@@ -257,35 +313,40 @@
257 313 \WCPOS\WooCommercePOS\Logger::log(
258 314 sprintf( 'Cloud print: PrintNode ESC/POS render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
259 315 );
260 316
261 - return '';
317 + return self::nothing_to_print();
262 318 }
263 319 }
264 320
265 - return '';
321 + return self::nothing_to_print();
266 322 }
267 323
268 324 if ( ! empty( $job['order_id'] ) && ! empty( $job['template_id'] ) ) {
269 325 $template = self::load_template( (string) $job['template_id'] );
270 326 if ( null === $template ) {
271 - return '';
327 + return self::nothing_to_print();
272 328 }
273 329
274 330 $printer = ( new Cloud_Print_Registry() )->get_printer( (string) $job['printer_id'] );
275 - $provider = $printer['provider'] ?? 'star-cloudprnt';
331 + $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
276 332 $wire = Provider::wire_format( $provider, (string) ( $template['engine'] ?? '' ) );
277 333 if ( null === $wire ) {
278 - return '';
334 + return self::nothing_to_print();
279 335 }
280 336
337 + $negotiated = '' === $media_type ? '' : Cloud_Print_Media_Types::wire_format( $media_type );
338 + if ( '' !== $negotiated ) {
339 + $wire = $negotiated;
340 + }
341 +
281 342 $order = wc_get_order( (int) $job['order_id'] );
282 343 if ( ! $order ) {
283 - return '';
344 + return self::nothing_to_print();
284 345 }
285 346
286 347 try {
287 - return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render(
348 + return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render_with_control(
288 349 $template,
289 350 $order,
290 351 $wire,
291 352 $this->drawer_render_options( $job )
@@ -297,9 +358,9 @@
297 358 \WCPOS\WooCommercePOS\Logger::log(
298 359 sprintf( 'Cloud print: thermal render failed for job %d: %s', (int) $job['id'], $e->getMessage() )
299 360 );
300 361
301 - return '';
362 + return self::nothing_to_print();
302 363 }
303 364 }
304 365
305 366 if ( ! empty( $job['order_id'] ) && ! empty( $job['format'] ) ) {
@@ -304,23 +365,59 @@
304 365
305 366 if ( ! empty( $job['order_id'] ) && ! empty( $job['format'] ) ) {
306 367 $order = wc_get_order( (int) $job['order_id'] );
307 368 if ( ! $order ) {
308 - return '';
369 + return self::nothing_to_print();
309 370 }
310 371
311 - $data = ( new Receipt_Data_Builder() )->build( $order, 'live' );
312 - $adapter = ( new Receipt_Output_Adapter_Factory() )->create( (string) $job['format'] );
372 + try {
373 + $data = ( new Receipt_Data_Builder() )->build( $order, 'live' );
374 + $adapter = ( new Receipt_Output_Adapter_Factory() )->create( (string) $job['format'] );
313 375
314 - return $adapter->transform( $data );
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 + }
315 388 }
316 389
317 390 $payload = base64_decode( (string) $job['payload'], true );
318 391
319 - return false === $payload ? '' : $payload;
392 + return self::in_band( false === $payload ? '' : $payload );
320 393 }
321 394
322 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 + /**
323 420 * Build drawer options for thermal rendering.
324 421 *
325 422 * @param array $job Job array.
326 423 *
@@ -333,9 +430,9 @@
333 430 );
334 431 }
335 432
336 433 /**
337 - * Query jobs by printer, status and/or order (newest first).
434 + * Query jobs by printer, status and/or order (oldest first).
338 435 *
339 436 * @param array $filters printer_id, status, order_id, limit.
340 437 *
341 438 * @return array<int, array>
@@ -378,8 +475,10 @@
378 475 */
379 476 public function query_rows( array $filters = array() ): array {
380 477 global $wpdb;
381 478
479 + $order = isset( $filters['order'] ) && 'DESC' === strtoupper( (string) $filters['order'] ) ? 'DESC' : 'ASC';
480 +
382 481 $query = new \WP_Query(
383 482 array(
384 483 'post_type' => self::POST_TYPE,
385 484 'post_status' => 'publish',
@@ -384,11 +483,14 @@
384 483 'post_type' => self::POST_TYPE,
385 484 'post_status' => 'publish',
386 485 'posts_per_page' => isset( $filters['limit'] ) ? (int) $filters['limit'] : 50,
387 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.
388 490 'orderby' => array(
389 - 'date' => 'ASC',
390 - 'ID' => 'ASC',
491 + 'date' => $order,
492 + 'ID' => $order,
391 493 ),
392 494 'fields' => 'ids',
393 495 'no_found_rows' => true,
394 496 'meta_query' => $this->filters_to_meta_query( $filters ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
@@ -419,8 +521,12 @@
419 521 'content_type' => (string) get_post_meta( $id, self::META_CTYPE, true ),
420 522 'order_id' => (int) get_post_meta( $id, self::META_ORDER_ID, true ),
421 523 'format' => (string) get_post_meta( $id, self::META_FORMAT, true ),
422 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 ),
423 529 );
424 530 },
425 531 $ids
426 532 );
@@ -428,9 +534,9 @@
428 534
429 535 /**
430 536 * Count jobs matching the same filters query() accepts.
431 537 *
432 - * @param array $filters printer_id / status / order_id / template_id.
538 + * @param array $filters printer_id / status / order_id / template_id / trigger / exclude_retried.
433 539 *
434 540 * @return int
435 541 */
436 542 public function count( array $filters = array() ): int {
@@ -454,9 +560,9 @@
454 560 * Replaces a per-printer count/oldest query fan-out — the queue view
455 561 * refreshes every 30 seconds, so its summary must cost one query no
456 562 * matter how many printers are registered.
457 563 *
458 - * @return array<string, array<string, array{count: int, oldest_gmt: string}>> printer_id => status => stats.
564 + * @return array<string, array<string, array{count: int, unresolved_count: int, oldest_gmt: string}>> printer_id => status => stats.
459 565 */
460 566 public function status_summary(): array {
461 567 global $wpdb;
462 568
@@ -463,16 +569,21 @@
463 569 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one aggregate pass; WP_Query would need 2 queries per printer.
464 570 $rows = $wpdb->get_results(
465 571 $wpdb->prepare(
466 572 "SELECT printer.meta_value AS printer_id, status.meta_value AS job_status,
467 - COUNT(DISTINCT p.ID) AS jobs, MIN(p.post_date_gmt) AS oldest_gmt
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
468 576 FROM {$wpdb->posts} p
469 577 INNER JOIN {$wpdb->postmeta} printer ON printer.post_id = p.ID AND printer.meta_key = %s
470 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
471 580 WHERE p.post_type = %s AND p.post_status = 'publish'
472 581 GROUP BY printer.meta_value, status.meta_value",
582 + self::STATUS_FAILED,
473 583 self::META_PRINTER,
474 584 self::META_STATUS,
585 + self::META_RETRIED_TO,
475 586 self::POST_TYPE
476 587 )
477 588 );
478 589
@@ -478,10 +589,11 @@
478 589
479 590 $summary = array();
480 591 foreach ( (array) $rows as $row ) {
481 592 $summary[ (string) $row->printer_id ][ (string) $row->job_status ] = array(
482 - 'count' => (int) $row->jobs,
483 - 'oldest_gmt' => (string) $row->oldest_gmt,
593 + 'count' => (int) $row->jobs,
594 + 'unresolved_count' => (int) $row->unresolved_jobs,
595 + 'oldest_gmt' => (string) $row->oldest_gmt,
484 596 );
485 597 }
486 598
487 599 return $summary;
@@ -571,8 +683,33 @@
571 683 return $cancelled;
572 684 }
573 685
574 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 + /**
575 712 * Atomically cancel a waiting job while excluding provider submission.
576 713 *
577 714 * @param int $id Job ID.
578 715 *
@@ -626,11 +763,18 @@
626 763 */
627 764 private function filters_to_meta_query( array $filters ): array {
628 765 $meta_query = array();
629 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'] );
630 773 $meta_query[] = array(
631 - 'key' => self::META_PRINTER,
632 - 'value' => sanitize_text_field( $filters['printer_id'] ),
774 + 'key' => self::META_PRINTER,
775 + 'value' => $printer_id,
776 + 'compare' => \is_array( $printer_id ) ? 'IN' : '=',
633 777 );
634 778 }
635 779 if ( ! empty( $filters['status'] ) ) {
636 780 // A single status matches exactly; a list becomes an IN clause
@@ -637,13 +781,33 @@
637 781 // (the queue's default "active" view is pending + claimed + failed).
638 782 $status = \is_array( $filters['status'] )
639 783 ? array_map( 'sanitize_text_field', $filters['status'] )
640 784 : sanitize_text_field( $filters['status'] );
641 - $meta_query[] = array(
642 - 'key' => self::META_STATUS,
643 - 'value' => $status,
644 - 'compare' => \is_array( $status ) ? 'IN' : '=',
645 - );
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 + }
646 810 }
647 811 if ( ! empty( $filters['order_id'] ) ) {
648 812 $meta_query[] = array(
649 813 'key' => self::META_ORDER_ID,
@@ -656,8 +820,24 @@
656 820 'key' => self::META_TEMPLATE,
657 821 'value' => sanitize_text_field( (string) $filters['template_id'] ),
658 822 );
659 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 + }
660 840
661 841 return $meta_query;
662 842 }
663 843
@@ -672,8 +852,25 @@
672 852 $this->finalize_status_change( $id, $status );
673 853 }
674 854
675 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 + /**
676 873 * Apply side effects for a status change.
677 874 *
678 875 * @param int $id Job ID.
679 876 * @param string $status New status.
@@ -689,19 +886,28 @@
689 886 // Terminal success (or abandonment): the payload has done its
690 887 // job, and a raster receipt is hundreds of KB. The row survives
691 888 // with metadata only — that's all the duplicate-trigger guard
692 889 // and the queue's history view need. Failed jobs keep their
693 - // payload so Retry can copy it.
694 - wp_update_post(
695 - array(
696 - 'ID' => $id,
697 - 'post_content' => '',
698 - )
699 - );
890 + // payload so Retry can copy it until a replacement is created.
891 + $this->strip_payload( $id );
700 892 }
701 893 }
702 894
703 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 + /**
704 910 * Acquire the atomic per-job lifecycle lock.
705 911 *
706 912 * @param int $id Job ID.
707 913 *
@@ -892,11 +1098,77 @@
892 1098 return $claimed[0];
893 1099 }
894 1100
895 1101 /**
896 - * Re-queue stale claims for a printer (crashed/aborted prints).
1102 + * The printer's newest unconfirmed, unresolved job within
1103 + * UNCONFIRMED_RESULT_WINDOW, or null.
897 1104 *
898 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.
899 1171 * @param int $ttl Claim TTL in seconds.
900 1172 */
901 1173 public function release_stale_claims( string $printer_id, int $ttl = self::CLAIM_TTL ): void {
902 1174 $claimed = $this->query(
@@ -910,13 +1182,25 @@
910 1182 if ( 0 === $claimed_at || ( time() - $claimed_at ) > $ttl ) {
911 1183 // Drop the timestamp while the job is still claimed — nothing
912 1184 // can re-claim it until the status flips, so a fresh claim's
913 1185 // timestamp can never be erased by this cleanup. Then the
914 - // requeue is conditional on still-claimed: same race as
1186 + // failure is conditional on still-claimed: same race as
915 1187 // try_claim() — a cancellation landing after the query above
916 - // must not be overwritten back to pending.
1188 + // must not be overwritten as failed.
917 1189 delete_post_meta( $job['id'], self::META_CLAIMED_AT );
918 - update_post_meta( $job['id'], self::META_STATUS, self::STATUS_PENDING, self::STATUS_CLAIMED );
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 + }
919 1203 }
920 1204 }
921 1205 }
922 1206