PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / order-shipment.php

order-shipment.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/order-shipment.php

178 lines 7.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use StoreEngine\Utils\Constants;
6 use StoreEngine\Utils\Helper;
7 use WP_Error;
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Records a shipment against a single order line item.
15 *
16 * Single source of truth for the per-item fulfilment flow shared by the admin
17 * order screen and the multi-vendor vendor dashboard, so the two can't drift:
18 * validates the status (enum + forward-only + digital), writes the status to
19 * the lookup row (keyed by order_item_id — never product_id, which would touch
20 * duplicate-product lines), stores courier/tracking as order meta, logs an
21 * order note, and fires the shipment + core delivery hooks.
22 *
23 * Callers layer their own authorisation on top (vendors check ownership; the
24 * admin path is gated by capability at the AJAX layer).
25 */
26 class OrderShipment {
27
28 /**
29 * @param int $order_item_id Lookup PRIMARY KEY of the line being shipped.
30 * @param string $new_status One of Constants::get_shipping_statuses().
31 * @param array $tracking [courier, tracking_number, tracking_url].
32 * @param string $actor_label Who performed it (vendor store / "Store admin").
33 *
34 * @return array|WP_Error Shipment result on success.
35 */
36 public static function record( int $order_item_id, string $new_status, array $tracking = [], string $actor_label = '' ) {
37 global $wpdb;
38
39 $statuses = Constants::get_shipping_statuses();
40 if ( ! in_array( $new_status, $statuses, true ) ) {
41 return new WP_Error( 'bad_status', __( 'Invalid shipping status.', 'storeengine' ), [ 'status' => 400 ] );
42 }
43
44 $lookup = $wpdb->prefix . 'storeengine_order_product_lookup';
45
46 // Positive rows only — the commission calculator stores refund markers on
47 // synthetic negative order_item_id rows that reuse this column.
48 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct read of a custom StoreEngine lookup table; not cacheable per request.
49 $row = $wpdb->get_row( $wpdb->prepare(
50 'SELECT * FROM %i WHERE order_item_id = %d AND order_item_id > 0',
51 $lookup,
52 $order_item_id
53 ) );
54 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
55 if ( ! $row ) {
56 return new WP_Error( 'not_found', __( 'Order item not found.', 'storeengine' ), [ 'status' => 404 ] );
57 }
58
59 $order_id = (int) $row->order_id;
60 $product_id = (int) $row->product_id;
61
62 if ( 'digital' === get_post_meta( $product_id, '_storeengine_product_shipping_type', true ) ) {
63 return new WP_Error( 'not_shippable', __( 'Shipping is not applicable for digital products.', 'storeengine' ), [ 'status' => 400 ] );
64 }
65
66 $current_status = (string) $row->shipping_status;
67 $cur_idx = array_search( $current_status, $statuses, true );
68 $next_idx = array_search( $new_status, $statuses, true );
69 if ( false !== $cur_idx && false !== $next_idx && $next_idx < $cur_idx ) {
70 return new WP_Error( 'backward_status', __( 'You cannot move back to a previous shipping status.', 'storeengine' ), [ 'status' => 409 ] );
71 }
72
73 // 1) Status on the lookup row, keyed by order_item_id.
74 // phpcs:disable WordPress.DB.DirectDatabaseQuery
75 $wpdb->update(
76 $lookup,
77 [ 'shipping_status' => $new_status ],
78 [ 'order_item_id' => $order_item_id ],
79 [ '%s' ],
80 [ '%d' ]
81 );
82 // phpcs:enable
83
84 // 2) Courier/tracking on the order entity meta (per line item).
85 $order = Helper::get_order( $order_id );
86 if ( is_wp_error( $order ) ) {
87 return new WP_Error( 'not_found', __( 'Order not found.', 'storeengine' ), [ 'status' => 404 ] );
88 }
89
90 $meta_key = '_storeengine_shipment_' . $order_item_id;
91 $existing = $order->get_meta( $meta_key );
92 $existing = is_array( $existing ) ? $existing : [];
93
94 $courier = sanitize_text_field( (string) ( $tracking['courier'] ?? '' ) );
95 $tracking_no = sanitize_text_field( (string) ( $tracking['tracking_number'] ?? '' ) );
96 $track_url = esc_url_raw( (string) ( $tracking['tracking_url'] ?? '' ) );
97
98 $shipped_idx = array_search( Constants::SHIPPED, $statuses, true );
99 $is_shipped = ( false !== $next_idx && $next_idx >= $shipped_idx );
100
101 $shipped_at = (string) ( $existing['shipped_at'] ?? '' );
102 if ( ! $shipped_at && $is_shipped ) {
103 $shipped_at = current_time( 'mysql', 1 );
104 }
105
106 $tracking_changed = ( $tracking_no !== (string) ( $existing['tracking_number'] ?? '' ) );
107
108 $shipment = [
109 'courier' => $courier,
110 'tracking_number' => $tracking_no,
111 'tracking_url' => $track_url,
112 'shipped_at' => $shipped_at,
113 'vendor_id' => (int) $row->vendor_id,
114 'status' => $new_status,
115 ];
116
117 $order->update_meta_data( $meta_key, $shipment );
118 $order->save();
119
120 // 3) Order note (always).
121 $item_name = get_the_title( $product_id ) ?: ( '#' . $product_id );
122 $status_label = Constants::get_shipping_status_label( $new_status );
123 $actor = $actor_label ?: __( 'Store admin', 'storeengine' );
124
125 $note_parts = [
126 sprintf(
127 /* translators: 1: who shipped (vendor/admin), 2: item name, 3: status label */
128 __( '%1$s updated “%2$s” to %3$s.', 'storeengine' ),
129 $actor,
130 $item_name,
131 $status_label
132 ),
133 ];
134 if ( $courier ) {
135 /* translators: %s: courier name */
136 $note_parts[] = sprintf( __( 'Courier: %s', 'storeengine' ), $courier );
137 }
138 if ( $tracking_no ) {
139 /* translators: %s: tracking number */
140 $note_parts[] = sprintf( __( 'Tracking: %s', 'storeengine' ), $tracking_no );
141 }
142 $order->add_order_note( implode( ' ', $note_parts ), 0, true );
143
144 // 4) Customer notification — only on shipped-or-later AND either it just
145 // entered shipped-territory or the tracking changed (no micro-spam).
146 $entered_shipped = $is_shipped && ( false === $cur_idx || $cur_idx < $shipped_idx );
147 if ( $is_shipped && ( $entered_shipped || $tracking_changed ) ) {
148 do_action( 'storeengine/order/item_shipped', $order_id, $order_item_id, $product_id, $shipment, $new_status );
149 }
150
151 // 5) Core delivery hooks (same as the legacy admin shipping AJAX).
152 do_action( 'storeengine/before_single_product_delivered', $product_id, $order_id, $current_status, $new_status );
153
154 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct read of a custom StoreEngine lookup table; not cacheable per request.
155 $not_delivered = (int) $wpdb->get_var( $wpdb->prepare(
156 'SELECT COUNT(*) FROM %i WHERE order_id = %d AND order_item_id > 0 AND shipping_status <> %s',
157 $lookup,
158 $order_id,
159 Constants::DELIVERED
160 ) );
161 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
162 if ( 0 === $not_delivered ) {
163 do_action( 'storeengine/all_product_delivered', $order_id );
164 }
165
166 do_action( 'storeengine/after_single_product_delivered', $product_id, $order_id, $current_status, $new_status );
167
168 return [
169 'order_item_id' => $order_item_id,
170 'order_id' => $order_id,
171 'shipping_status' => $new_status,
172 'status_label' => $status_label,
173 'shipment' => $shipment,
174 'all_delivered' => ( 0 === $not_delivered ),
175 ];
176 }
177 }
178