| @@ -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 | |