← All changes
|
includes/Services/Cloud_Print_Trigger_Service.php
+245
-88
1.9.16
→
1.10.20
View file →
| @@ -12,8 +12,20 @@ | ||
| 12 | 12 | /** |
| 13 | 13 | * Cloud_Print_Trigger_Service class. |
| 14 | 14 | */ |
| 15 | 15 | class Cloud_Print_Trigger_Service { |
| 16 | + /** | |
| 17 | + * The Cloud Print settings option key. | |
| 18 | + * | |
| 19 | + * Read directly rather than through Cloud_Print_Section: the section's | |
| 20 | + * read() decorates rows with live printer status, which means outbound | |
| 21 | + * HTTP, and this class runs on woocommerce_new_order and | |
| 22 | + * woocommerce_order_status_changed. Network calls do not belong on the | |
| 23 | + * checkout path. It also redacts secrets, which the printer-poll and | |
| 24 | + * PrintNode paths need intact. | |
| 25 | + * | |
| 26 | + * @var string | |
| 27 | + */ | |
| 16 | 28 | const OPTION = 'woocommerce_pos_settings_cloud_print'; |
| 17 | 29 | |
| 18 | 30 | /** |
| 19 | 31 | * Cron hook used to submit a PrintNode job out-of-band (never on checkout). |
| @@ -20,8 +32,18 @@ | ||
| 20 | 32 | */ |
| 21 | 33 | const CRON_SUBMIT = 'wcpos_cloud_print_submit'; |
| 22 | 34 | |
| 23 | 35 | /** |
| 36 | + * Seconds before an abandoned assignment lock may be reclaimed. | |
| 37 | + */ | |
| 38 | + const ASSIGNMENT_LOCK_TTL = 120; | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * Default assignment trigger: never print before the customer has paid. | |
| 42 | + */ | |
| 43 | + const DEFAULT_TRIGGER = 'paid'; | |
| 44 | + | |
| 45 | + /** | |
| 24 | 46 | * Job store. |
| 25 | 47 | * |
| 26 | 48 | * @var Print_Job_Service |
| 27 | 49 | */ |
| @@ -27,8 +49,20 @@ | ||
| 27 | 49 | */ |
| 28 | 50 | private $jobs; |
| 29 | 51 | |
| 30 | 52 | /** |
| 53 | + * Order ids whose woocommerce_payment_complete fired this request. | |
| 54 | + * | |
| 55 | + * The payment event is the authoritative "paid" signal: WCPOS routes | |
| 56 | + * payment_complete() to a merchant-configured per-gateway status (see | |
| 57 | + * Orders::payment_complete_order_status), which may not be one of | |
| 58 | + * wc_get_is_paid_statuses() — e.g. on-hold for account sales. | |
| 59 | + * | |
| 60 | + * @var array<int, bool> | |
| 61 | + */ | |
| 62 | + private $payment_completed = array(); | |
| 63 | + | |
| 64 | + /** | |
| 31 | 65 | * Printer registry. |
| 32 | 66 | * |
| 33 | 67 | * @var Cloud_Print_Registry |
| 34 | 68 | */ |
| @@ -41,11 +75,56 @@ | ||
| 41 | 75 | $this->jobs = new Print_Job_Service(); |
| 42 | 76 | $this->registry = new Cloud_Print_Registry(); |
| 43 | 77 | add_action( 'woocommerce_new_order', array( $this, 'handle_order' ), 20, 1 ); |
| 44 | 78 | add_action( 'woocommerce_order_status_changed', array( $this, 'handle_order' ), 20, 1 ); |
| 79 | + add_action( 'woocommerce_payment_complete', array( $this, 'handle_paid_order' ), 20, 1 ); | |
| 45 | 80 | } |
| 46 | 81 | |
| 47 | 82 | /** |
| 83 | + * Handle payment completing for an order. | |
| 84 | + * | |
| 85 | + * Runs after WC_Order::payment_complete() has moved the order to its | |
| 86 | + * post-payment status, which a status-changed callback may have already | |
| 87 | + * seen as a non-paid status. Remember the paid signal, then re-evaluate. | |
| 88 | + * | |
| 89 | + * @param int $order_id Order ID. | |
| 90 | + */ | |
| 91 | + public function handle_paid_order( $order_id ): void { | |
| 92 | + $this->payment_completed[ (int) $order_id ] = true; | |
| 93 | + $this->handle_order( $order_id ); | |
| 94 | + } | |
| 95 | + | |
| 96 | + /** | |
| 97 | + * Normalize an assignment trigger to a supported value. | |
| 98 | + * | |
| 99 | + * Shared by the order-event path, sanitize-on-write, and normalize-on-read | |
| 100 | + * so the three defaulting sites cannot drift: a drifted default here would | |
| 101 | + * print receipts for unpaid orders. | |
| 102 | + * | |
| 103 | + * @param mixed $trigger Raw trigger value. | |
| 104 | + * | |
| 105 | + * @return string created|paid. | |
| 106 | + */ | |
| 107 | + public static function normalize_trigger( $trigger ): string { | |
| 108 | + return \in_array( $trigger, array( 'created', 'paid' ), true ) ? $trigger : self::DEFAULT_TRIGGER; | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** | |
| 112 | + * Order-meta key holding how many jobs an assignment has already fired. | |
| 113 | + * | |
| 114 | + * Keyed by the same triple the job count filters on, so two rules that | |
| 115 | + * differ only by trigger keep separate marks. Hashed because a template id | |
| 116 | + * can be an arbitrary virtual slug and meta keys have a length limit. | |
| 117 | + * | |
| 118 | + * @param string $printer_id Printer id. | |
| 119 | + * @param string $template_id Template id. | |
| 120 | + * @param string $trigger Normalized trigger. | |
| 121 | + */ | |
| 122 | + private static function fired_meta_key( string $printer_id, string $template_id, string $trigger ): string { | |
| 123 | + return '_wcpos_cp_fired_' . md5( $printer_id . "\0" . $template_id . "\0" . $trigger ); | |
| 124 | + } | |
| 125 | + | |
| 126 | + /** | |
| 48 | 127 | * Create jobs for an order according to the configured assignments. |
| 49 | 128 | * |
| 50 | 129 | * @param int $order_id Order ID. |
| 51 | 130 | */ |
| @@ -83,43 +162,131 @@ | ||
| 83 | 162 | $scope = isset( $assignment['scope'] ) ? (string) $assignment['scope'] : 'every'; |
| 84 | 163 | if ( ! $this->scope_matches( $scope, $is_pos ) ) { |
| 85 | 164 | continue; |
| 86 | 165 | } |
| 87 | - if ( $this->already_queued( $order->get_id(), (string) $assignment['printer_id'], (string) $assignment['template_id'] ) ) { | |
| 166 | + $trigger = self::normalize_trigger( $assignment['trigger'] ?? '' ); | |
| 167 | + if ( ! $this->payment_state_matches( $trigger, $order ) ) { | |
| 88 | 168 | continue; |
| 89 | 169 | } |
| 90 | - | |
| 91 | - $printer = $this->registry->get_printer( (string) $assignment['printer_id'] ); | |
| 92 | - if ( empty( $printer ) ) { | |
| 170 | + $printer_id = (string) $assignment['printer_id']; | |
| 171 | + $template_id = (string) $assignment['template_id']; | |
| 172 | + $order_id = $order->get_id(); | |
| 173 | + $lock = 'wcpos_cloud_print_assignment_lock_' . md5( $order_id . "\0" . $printer_id . "\0" . $template_id ); | |
| 174 | + if ( ! $this->acquire_assignment_lock( $lock ) ) { | |
| 93 | 175 | continue; |
| 94 | 176 | } |
| 95 | - $provider = (string) ( $printer['provider'] ?? '' ); | |
| 177 | + try { | |
| 178 | + // Not a duplicate of the Settings Section's clamp: this one guards | |
| 179 | + // the output of the woocommerce_pos_cloud_print_assignments filter, | |
| 180 | + // which Pro substitutes rows into (Cloud_Print_Per_Outlet). Rows | |
| 181 | + // that arrive through the filter never passed the section's | |
| 182 | + // sanitizer, so an extension can hand us copies: 999. Keep it. | |
| 183 | + $copies = min( 5, max( 1, (int) ( $assignment['copies'] ?? 1 ) ) ); | |
| 184 | + // Dedupe per trigger: a created-rule job must not satisfy a | |
| 185 | + // paid rule for the same printer+template (and vice versa). | |
| 186 | + // Trigger-less jobs (manual prints, pre-trigger installs) | |
| 187 | + // still count toward every rule. | |
| 188 | + $existing = $this->jobs->count( | |
| 189 | + array( | |
| 190 | + 'printer_id' => $printer_id, | |
| 191 | + 'order_id' => $order_id, | |
| 192 | + 'template_id' => $template_id, | |
| 193 | + 'trigger' => $trigger, | |
| 194 | + ) | |
| 195 | + ); | |
| 196 | + // Counting rows alone cannot dedupe: the rows are deletable (by | |
| 197 | + // the admin, and by the retention purge), and handle_order() | |
| 198 | + // runs again on every later status change. A deleted receipt | |
| 199 | + // would then read as never printed and be queued a second time | |
| 200 | + // — including one the admin had deliberately cancelled. The | |
| 201 | + // high-water mark survives the rows it counts. | |
| 202 | + $fired_key = self::fired_meta_key( $printer_id, $template_id, $trigger ); | |
| 203 | + $fired = (int) $order->get_meta( $fired_key ); | |
| 204 | + $shortfall = max( 0, $copies - max( $existing, $fired ) ); | |
| 205 | + if ( 0 === $shortfall ) { | |
| 206 | + continue; | |
| 207 | + } | |
| 96 | 208 | |
| 97 | - $template_id = (string) $assignment['template_id']; | |
| 98 | - $template = Print_Job_Service::load_template( $template_id ); | |
| 99 | - if ( null === $template ) { | |
| 100 | - continue; | |
| 209 | + $printer = $this->registry->get_printer( $printer_id ); | |
| 210 | + if ( empty( $printer ) ) { | |
| 211 | + continue; | |
| 212 | + } | |
| 213 | + // Legacy printer rows may lack a stored provider; normalize() maps | |
| 214 | + // them to the star-cloudprnt default like every other read path. | |
| 215 | + $provider = Provider::normalize( (string) ( $printer['provider'] ?? '' ) ); | |
| 216 | + | |
| 217 | + $template = Print_Job_Service::load_template( $template_id ); | |
| 218 | + if ( null === $template ) { | |
| 219 | + continue; | |
| 220 | + } | |
| 221 | + | |
| 222 | + for ( $copy = 0; $copy < $shortfall; $copy++ ) { | |
| 223 | + $job_id = self::enqueue_order_job( | |
| 224 | + $this->jobs, | |
| 225 | + $printer_id, | |
| 226 | + $printer, | |
| 227 | + $order_id, | |
| 228 | + $template_id, | |
| 229 | + $template, | |
| 230 | + array(), | |
| 231 | + $trigger | |
| 232 | + ); | |
| 233 | + if ( $job_id > 0 ) { | |
| 234 | + ++$fired; | |
| 235 | + $order->update_meta_data( $fired_key, (string) $fired ); | |
| 236 | + $order->save_meta_data(); | |
| 237 | + } | |
| 238 | + if ( 0 === $job_id ) { | |
| 239 | + Logger::log( | |
| 240 | + sprintf( | |
| 241 | + 'Cloud print: skipping assignment for printer "%s" — template "%s" is not printable on provider "%s".', | |
| 242 | + $printer_id, | |
| 243 | + $template_id, | |
| 244 | + $provider | |
| 245 | + ) | |
| 246 | + ); | |
| 247 | + break; | |
| 248 | + } | |
| 249 | + } | |
| 250 | + } finally { | |
| 251 | + delete_option( $lock ); | |
| 101 | 252 | } |
| 253 | + } | |
| 254 | + } | |
| 102 | 255 | |
| 103 | - $job_id = self::enqueue_order_job( | |
| 104 | - $this->jobs, | |
| 105 | - (string) $assignment['printer_id'], | |
| 106 | - $printer, | |
| 107 | - $order->get_id(), | |
| 108 | - $template_id, | |
| 109 | - $template | |
| 110 | - ); | |
| 111 | - if ( 0 === $job_id ) { | |
| 112 | - Logger::log( | |
| 113 | - sprintf( | |
| 114 | - 'Cloud print: skipping assignment for printer "%s" — template "%s" is not printable on provider "%s".', | |
| 115 | - (string) $assignment['printer_id'], | |
| 116 | - $template_id, | |
| 117 | - $provider | |
| 118 | - ) | |
| 119 | - ); | |
| 256 | + /** | |
| 257 | + * Acquire the lock covering copy counting and job creation. | |
| 258 | + * | |
| 259 | + * @param string $option Lock option name. | |
| 260 | + */ | |
| 261 | + private function acquire_assignment_lock( string $option ): bool { | |
| 262 | + $now = time(); | |
| 263 | + | |
| 264 | + if ( add_option( $option, (string) $now, '', false ) ) { | |
| 265 | + return true; | |
| 266 | + } | |
| 267 | + | |
| 268 | + $locked_at = get_option( $option, 0 ); | |
| 269 | + if ( (int) $locked_at > 0 && ( $now - (int) $locked_at ) > self::ASSIGNMENT_LOCK_TTL ) { | |
| 270 | + global $wpdb; | |
| 271 | + // The value predicate prevents deleting a lock replaced after get_option(). | |
| 272 | + $deleted = $wpdb->delete( | |
| 273 | + $wpdb->options, | |
| 274 | + array( | |
| 275 | + 'option_name' => $option, | |
| 276 | + 'option_value' => (string) $locked_at, | |
| 277 | + ), | |
| 278 | + array( '%s', '%s' ) | |
| 279 | + ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic option delete; cache cleared below. | |
| 280 | + if ( 1 !== $deleted ) { | |
| 281 | + return false; | |
| 120 | 282 | } |
| 283 | + wp_cache_delete( $option, 'options' ); | |
| 284 | + | |
| 285 | + return add_option( $option, (string) $now, '', false ); | |
| 121 | 286 | } |
| 287 | + | |
| 288 | + return false; | |
| 122 | 289 | } |
| 123 | 290 | |
| 124 | 291 | /** |
| 125 | 292 | * Enqueue a print job for an order + template, deriving the wire format from |
| @@ -136,55 +303,39 @@ | ||
| 136 | 303 | * @param int $order_id Order id to render. |
| 137 | 304 | * @param string $template_id Template id (numeric) or virtual slug. |
| 138 | 305 | * @param array $template Loaded template array. |
| 139 | 306 | * @param array $drawer_options Drawer options. |
| 307 | + * @param string $trigger Originating rule trigger (created|paid); empty for manual prints. | |
| 140 | 308 | * |
| 141 | 309 | * @return int Created job id, or 0 when the template is not printable on the provider. |
| 142 | 310 | */ |
| 143 | - public static function enqueue_order_job( Print_Job_Service $jobs, string $printer_id, array $printer, int $order_id, string $template_id, array $template, array $drawer_options = array() ): int { | |
| 144 | - $provider = (string) ( $printer['provider'] ?? '' ); | |
| 311 | + public static function enqueue_order_job( Print_Job_Service $jobs, string $printer_id, array $printer, int $order_id, string $template_id, array $template, array $drawer_options = array(), string $trigger = '' ): int { | |
| 312 | + // Normalize before EVERY consumer below (drawer options, printability, | |
| 313 | + // requires_submit) — a legacy row without a provider is star-cloudprnt. | |
| 314 | + $provider = Provider::normalize( (string) ( $printer['provider'] ?? '' ) ); | |
| 145 | 315 | $drawer_options = self::drawer_options_for_provider( $provider, $drawer_options ); |
| 146 | 316 | |
| 147 | - if ( 'printnode' === $provider ) { | |
| 148 | - $fmt = ( new Print_Format_Resolver() )->resolve( $printer, $template ); | |
| 149 | - if ( '' === $fmt['kind'] ) { | |
| 150 | - return 0; | |
| 151 | - } | |
| 152 | - | |
| 153 | - $job_id = $jobs->create( | |
| 154 | - array( | |
| 155 | - 'printer_id' => $printer_id, | |
| 156 | - 'order_id' => $order_id, | |
| 157 | - 'template_id' => $template_id, | |
| 158 | - 'content_type' => $fmt['content_type'], | |
| 159 | - 'pn_kind' => $fmt['kind'], | |
| 160 | - 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ), | |
| 161 | - 'drawer_connector' => $drawer_options['drawer_connector'], | |
| 162 | - ) | |
| 163 | - ); | |
| 164 | - if ( $job_id > 0 ) { | |
| 165 | - wp_schedule_single_event( time(), self::CRON_SUBMIT, array( $job_id ) ); | |
| 166 | - } | |
| 167 | - | |
| 168 | - return $job_id; | |
| 317 | + // Provider::format() owns both halves of the answer for every provider: an | |
| 318 | + // empty kind means the template cannot be rendered on this printer. | |
| 319 | + $fmt = Provider::format( $printer, $template ); | |
| 320 | + if ( '' === $fmt['kind'] ) { | |
| 321 | + return 0; | |
| 169 | 322 | } |
| 170 | 323 | |
| 171 | - $engine = (string) ( $template['engine'] ?? '' ); | |
| 172 | - $wire = Provider::wire_format( $provider, $engine ); | |
| 173 | - if ( null === $wire ) { | |
| 174 | - return 0; | |
| 324 | + $job_args = array( | |
| 325 | + 'printer_id' => $printer_id, | |
| 326 | + 'content_type' => $fmt['content_type'], | |
| 327 | + 'order_id' => $order_id, | |
| 328 | + 'template_id' => $template_id, | |
| 329 | + 'trigger' => $trigger, | |
| 330 | + 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ), | |
| 331 | + 'drawer_connector' => $drawer_options['drawer_connector'], | |
| 332 | + ); | |
| 333 | + if ( Provider::stores_job_kind( $provider ) ) { | |
| 334 | + $job_args['pn_kind'] = $fmt['kind']; | |
| 175 | 335 | } |
| 176 | 336 | |
| 177 | - $job_id = $jobs->create( | |
| 178 | - array( | |
| 179 | - 'printer_id' => $printer_id, | |
| 180 | - 'content_type' => Provider::content_type( $provider ), | |
| 181 | - 'order_id' => $order_id, | |
| 182 | - 'template_id' => $template_id, | |
| 183 | - 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ), | |
| 184 | - 'drawer_connector' => $drawer_options['drawer_connector'], | |
| 185 | - ) | |
| 186 | - ); | |
| 337 | + $job_id = $jobs->create( $job_args ); | |
| 187 | 338 | |
| 188 | 339 | // Push providers (e.g. Star Online) don't poll us; submit out-of-band. |
| 189 | 340 | if ( $job_id > 0 && Provider::requires_submit( $provider ) ) { |
| 190 | 341 | wp_schedule_single_event( time(), self::CRON_SUBMIT, array( $job_id ) ); |
| @@ -193,12 +344,14 @@ | ||
| 193 | 344 | return $job_id; |
| 194 | 345 | } |
| 195 | 346 | |
| 196 | 347 | /** |
| 197 | - * Keep drawer metadata scoped to providers implemented by this server change. | |
| 348 | + * Keep drawer metadata scoped to providers that can act on it. | |
| 198 | 349 | * |
| 199 | - * Star providers use Star-specific drawer commands and are intentionally not | |
| 200 | - * changed by the Epson/PrintNode implementation. | |
| 350 | + * Zeroing it elsewhere is not cosmetic: a job that carries drawer metadata a | |
| 351 | + * renderer never reads would promise the cashier a drawer kick that never | |
| 352 | + * fires. Star Online is the remaining opt-out — stario.online renders our | |
| 353 | + * markup and the markup has no drawer verb. | |
| 201 | 354 | * |
| 202 | 355 | * @param string $provider Provider key. |
| 203 | 356 | * @param array $drawer_options Drawer options. |
| 204 | 357 | * |
| @@ -204,9 +357,9 @@ | ||
| 204 | 357 | * |
| 205 | 358 | * @return array{auto_open_drawer:bool, drawer_connector:string} |
| 206 | 359 | */ |
| 207 | 360 | private static function drawer_options_for_provider( string $provider, array $drawer_options ): array { |
| 208 | - if ( ! in_array( $provider, array( 'epson-sdp', 'printnode' ), true ) ) { | |
| 361 | + if ( ! Provider::supports_drawer( $provider ) ) { | |
| 209 | 362 | return array( |
| 210 | 363 | 'auto_open_drawer' => false, |
| 211 | 364 | 'drawer_connector' => 'pin2', |
| 212 | 365 | ); |
| @@ -218,8 +371,32 @@ | ||
| 218 | 371 | ); |
| 219 | 372 | } |
| 220 | 373 | |
| 221 | 374 | /** |
| 375 | + * Whether an assignment trigger applies to this order's payment state. | |
| 376 | + * | |
| 377 | + * POS carts ARE orders from the moment the cart is saved (status | |
| 378 | + * pos-open), and online orders exist at checkout as pending — so | |
| 379 | + * 'created' fires before the customer has paid. 'paid' (the default) | |
| 380 | + * accepts any of three signals: a paid status per | |
| 381 | + * wc_get_is_paid_statuses(), the woocommerce_payment_complete event seen | |
| 382 | + * this request, or a stored date_paid — the latter two cover gateways | |
| 383 | + * whose configured post-payment status is not a WC paid status. | |
| 384 | + * | |
| 385 | + * @param string $trigger created|paid. | |
| 386 | + * @param \WC_Order $order The order being processed. | |
| 387 | + */ | |
| 388 | + private function payment_state_matches( string $trigger, \WC_Order $order ): bool { | |
| 389 | + if ( 'created' === $trigger ) { | |
| 390 | + return true; | |
| 391 | + } | |
| 392 | + | |
| 393 | + return $order->is_paid() | |
| 394 | + || ! empty( $this->payment_completed[ $order->get_id() ] ) | |
| 395 | + || null !== $order->get_date_paid(); | |
| 396 | + } | |
| 397 | + | |
| 398 | + /** | |
| 222 | 399 | * Whether an assignment scope applies to this order origin. |
| 223 | 400 | * |
| 224 | 401 | * @param string $scope every|pos|online. |
| 225 | 402 | * @param bool $is_pos Whether the order was created via the POS. |
| @@ -235,26 +412,6 @@ | ||
| 235 | 412 | return ! $is_pos; |
| 236 | 413 | } |
| 237 | 414 | |
| 238 | 415 | return false; |
| 239 | - } | |
| 240 | - | |
| 241 | - /** | |
| 242 | - * Guard against duplicate jobs for the same order+printer+template. | |
| 243 | - * | |
| 244 | - * @param int $order_id Order ID. | |
| 245 | - * @param string $printer_id Printer ID. | |
| 246 | - * @param string $template_id Template ID. | |
| 247 | - */ | |
| 248 | - private function already_queued( int $order_id, string $printer_id, string $template_id ): bool { | |
| 249 | - $existing = $this->jobs->query( | |
| 250 | - array( | |
| 251 | - 'printer_id' => $printer_id, | |
| 252 | - 'order_id' => $order_id, | |
| 253 | - 'template_id' => $template_id, | |
| 254 | - 'limit' => 1, | |
| 255 | - ) | |
| 256 | - ); | |
| 257 | - | |
| 258 | - return ! empty( $existing ); | |
| 259 | 416 | } |
| 260 | 417 | } |