PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Services / Cloud_Print_Trigger_Service.php

Cloud_Print_Trigger_Service.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.0, at includes/Services/Cloud_Print_Trigger_Service.php

390 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Creates cloud print jobs from WooCommerce order events.
4 *
5 * @package WCPOS\WooCommercePOS\Services
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use WCPOS\WooCommercePOS\Logger;
11
12 /**
13 * Cloud_Print_Trigger_Service class.
14 */
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 */
28 const OPTION = 'woocommerce_pos_settings_cloud_print';
29
30 /**
31 * Cron hook used to submit a PrintNode job out-of-band (never on checkout).
32 */
33 const CRON_SUBMIT = 'wcpos_cloud_print_submit';
34
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 /**
46 * Job store.
47 *
48 * @var Print_Job_Service
49 */
50 private $jobs;
51
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 /**
65 * Printer registry.
66 *
67 * @var Cloud_Print_Registry
68 */
69 private $registry;
70
71 /**
72 * Constructor — hook order events.
73 */
74 public function __construct() {
75 $this->jobs = new Print_Job_Service();
76 $this->registry = new Cloud_Print_Registry();
77 add_action( 'woocommerce_new_order', array( $this, 'handle_order' ), 20, 1 );
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 );
80 }
81
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 * Create jobs for an order according to the configured assignments.
113 *
114 * @param int $order_id Order ID.
115 */
116 public function handle_order( $order_id ): void {
117 $order = wc_get_order( (int) $order_id );
118 if ( ! $order ) {
119 return;
120 }
121
122 $settings = get_option( self::OPTION, array() );
123 $assignments = isset( $settings['assignments'] ) && \is_array( $settings['assignments'] ) ? $settings['assignments'] : array();
124
125 /**
126 * Filter the cloud-print assignments for an order. Pro uses this to
127 * substitute per-outlet assignments based on the order's store.
128 *
129 * @param array $assignments Global assignments.
130 * @param \WC_Order $order The order being processed.
131 */
132 $assignments = apply_filters( 'woocommerce_pos_cloud_print_assignments', $assignments, $order );
133 if ( ! \is_array( $assignments ) ) {
134 $assignments = array();
135 }
136
137 if ( empty( $assignments ) ) {
138 return;
139 }
140
141 $is_pos = 'woocommerce-pos' === $order->get_created_via();
142
143 foreach ( $assignments as $assignment ) {
144 if ( empty( $assignment['printer_id'] ) || empty( $assignment['template_id'] ) ) {
145 continue;
146 }
147 $scope = isset( $assignment['scope'] ) ? (string) $assignment['scope'] : 'every';
148 if ( ! $this->scope_matches( $scope, $is_pos ) ) {
149 continue;
150 }
151 $trigger = self::normalize_trigger( $assignment['trigger'] ?? '' );
152 if ( ! $this->payment_state_matches( $trigger, $order ) ) {
153 continue;
154 }
155 $printer_id = (string) $assignment['printer_id'];
156 $template_id = (string) $assignment['template_id'];
157 $order_id = $order->get_id();
158 $lock = 'wcpos_cloud_print_assignment_lock_' . md5( $order_id . "\0" . $printer_id . "\0" . $template_id );
159 if ( ! $this->acquire_assignment_lock( $lock ) ) {
160 continue;
161 }
162 try {
163 // Not a duplicate of the Settings Section's clamp: this one guards
164 // the output of the woocommerce_pos_cloud_print_assignments filter,
165 // which Pro substitutes rows into (Cloud_Print_Per_Outlet). Rows
166 // that arrive through the filter never passed the section's
167 // sanitizer, so an extension can hand us copies: 999. Keep it.
168 $copies = min( 5, max( 1, (int) ( $assignment['copies'] ?? 1 ) ) );
169 // Dedupe per trigger: a created-rule job must not satisfy a
170 // paid rule for the same printer+template (and vice versa).
171 // Trigger-less jobs (manual prints, pre-trigger installs)
172 // still count toward every rule.
173 $existing = $this->jobs->count(
174 array(
175 'printer_id' => $printer_id,
176 'order_id' => $order_id,
177 'template_id' => $template_id,
178 'trigger' => $trigger,
179 )
180 );
181 $shortfall = max( 0, $copies - $existing );
182 if ( 0 === $shortfall ) {
183 continue;
184 }
185
186 $printer = $this->registry->get_printer( $printer_id );
187 if ( empty( $printer ) ) {
188 continue;
189 }
190 // Legacy printer rows may lack a stored provider; normalize() maps
191 // them to the star-cloudprnt default like every other read path.
192 $provider = Provider::normalize( (string) ( $printer['provider'] ?? '' ) );
193
194 $template = Print_Job_Service::load_template( $template_id );
195 if ( null === $template ) {
196 continue;
197 }
198
199 for ( $copy = 0; $copy < $shortfall; $copy++ ) {
200 $job_id = self::enqueue_order_job(
201 $this->jobs,
202 $printer_id,
203 $printer,
204 $order_id,
205 $template_id,
206 $template,
207 array(),
208 $trigger
209 );
210 if ( 0 === $job_id ) {
211 Logger::log(
212 sprintf(
213 'Cloud print: skipping assignment for printer "%s" — template "%s" is not printable on provider "%s".',
214 $printer_id,
215 $template_id,
216 $provider
217 )
218 );
219 break;
220 }
221 }
222 } finally {
223 delete_option( $lock );
224 }
225 }
226 }
227
228 /**
229 * Acquire the lock covering copy counting and job creation.
230 *
231 * @param string $option Lock option name.
232 */
233 private function acquire_assignment_lock( string $option ): bool {
234 $now = time();
235
236 if ( add_option( $option, (string) $now, '', false ) ) {
237 return true;
238 }
239
240 $locked_at = get_option( $option, 0 );
241 if ( (int) $locked_at > 0 && ( $now - (int) $locked_at ) > self::ASSIGNMENT_LOCK_TTL ) {
242 global $wpdb;
243 // The value predicate prevents deleting a lock replaced after get_option().
244 $deleted = $wpdb->delete(
245 $wpdb->options,
246 array(
247 'option_name' => $option,
248 'option_value' => (string) $locked_at,
249 ),
250 array( '%s', '%s' )
251 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic option delete; cache cleared below.
252 if ( 1 !== $deleted ) {
253 return false;
254 }
255 wp_cache_delete( $option, 'options' );
256
257 return add_option( $option, (string) $now, '', false );
258 }
259
260 return false;
261 }
262
263 /**
264 * Enqueue a print job for an order + template, deriving the wire format from
265 * the printer's provider. Shared by the order-event trigger and the manual
266 * print-jobs endpoint so the two cannot drift.
267 *
268 * For PrintNode the job's submit event is scheduled out-of-band (PrintNode
269 * does not poll). For polling providers (Star/Epson) the printer fetches the
270 * job on its next poll, so no submit is scheduled.
271 *
272 * @param Print_Job_Service $jobs Job store.
273 * @param string $printer_id Registered printer id.
274 * @param array $printer Registered printer config.
275 * @param int $order_id Order id to render.
276 * @param string $template_id Template id (numeric) or virtual slug.
277 * @param array $template Loaded template array.
278 * @param array $drawer_options Drawer options.
279 * @param string $trigger Originating rule trigger (created|paid); empty for manual prints.
280 *
281 * @return int Created job id, or 0 when the template is not printable on the provider.
282 */
283 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 {
284 // Normalize before EVERY consumer below (drawer options, printability,
285 // requires_submit) — a legacy row without a provider is star-cloudprnt.
286 $provider = Provider::normalize( (string) ( $printer['provider'] ?? '' ) );
287 $drawer_options = self::drawer_options_for_provider( $provider, $drawer_options );
288
289 // The resolver owns both halves of the answer for every provider: an
290 // empty kind means the template cannot be rendered on this printer.
291 $fmt = ( new Print_Format_Resolver() )->resolve( $printer, $template );
292 if ( '' === $fmt['kind'] ) {
293 return 0;
294 }
295
296 $job_args = array(
297 'printer_id' => $printer_id,
298 'content_type' => $fmt['content_type'],
299 'order_id' => $order_id,
300 'template_id' => $template_id,
301 'trigger' => $trigger,
302 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ),
303 'drawer_connector' => $drawer_options['drawer_connector'],
304 );
305 if ( Provider::stores_job_kind( $provider ) ) {
306 $job_args['pn_kind'] = $fmt['kind'];
307 }
308
309 $job_id = $jobs->create( $job_args );
310
311 // Push providers (e.g. Star Online) don't poll us; submit out-of-band.
312 if ( $job_id > 0 && Provider::requires_submit( $provider ) ) {
313 wp_schedule_single_event( time(), self::CRON_SUBMIT, array( $job_id ) );
314 }
315
316 return $job_id;
317 }
318
319 /**
320 * Keep drawer metadata scoped to providers that can act on it.
321 *
322 * Zeroing it elsewhere is not cosmetic: a job that carries drawer metadata a
323 * renderer never reads would promise the cashier a drawer kick that never
324 * fires. Star Online is the remaining opt-out — stario.online renders our
325 * markup and the markup has no drawer verb.
326 *
327 * @param string $provider Provider key.
328 * @param array $drawer_options Drawer options.
329 *
330 * @return array{auto_open_drawer:bool, drawer_connector:string}
331 */
332 private static function drawer_options_for_provider( string $provider, array $drawer_options ): array {
333 if ( ! Provider::supports_drawer( $provider ) ) {
334 return array(
335 'auto_open_drawer' => false,
336 'drawer_connector' => 'pin2',
337 );
338 }
339
340 return array(
341 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ),
342 'drawer_connector' => Print_Job_Service::normalize_drawer_connector( (string) ( $drawer_options['drawer_connector'] ?? 'pin2' ) ),
343 );
344 }
345
346 /**
347 * Whether an assignment trigger applies to this order's payment state.
348 *
349 * POS carts ARE orders from the moment the cart is saved (status
350 * pos-open), and online orders exist at checkout as pending — so
351 * 'created' fires before the customer has paid. 'paid' (the default)
352 * accepts any of three signals: a paid status per
353 * wc_get_is_paid_statuses(), the woocommerce_payment_complete event seen
354 * this request, or a stored date_paid — the latter two cover gateways
355 * whose configured post-payment status is not a WC paid status.
356 *
357 * @param string $trigger created|paid.
358 * @param \WC_Order $order The order being processed.
359 */
360 private function payment_state_matches( string $trigger, \WC_Order $order ): bool {
361 if ( 'created' === $trigger ) {
362 return true;
363 }
364
365 return $order->is_paid()
366 || ! empty( $this->payment_completed[ $order->get_id() ] )
367 || null !== $order->get_date_paid();
368 }
369
370 /**
371 * Whether an assignment scope applies to this order origin.
372 *
373 * @param string $scope every|pos|online.
374 * @param bool $is_pos Whether the order was created via the POS.
375 */
376 private function scope_matches( string $scope, bool $is_pos ): bool {
377 if ( 'every' === $scope ) {
378 return true;
379 }
380 if ( 'pos' === $scope ) {
381 return $is_pos;
382 }
383 if ( 'online' === $scope ) {
384 return ! $is_pos;
385 }
386
387 return false;
388 }
389 }
390