<?php
namespace Automattic\WCShipping\Shipments;
use Automattic\WCShipping\Connect\WC_Connect_Service_Settings_Store;
use Automattic\WCShipping\Shipment\ShipmentFromLabelGenerator;
use Automattic\WCShipping\Shipments\Models\Shipments;
use Exception;
use InvalidArgumentException;
use WC_Order;
use Automattic\WCShipping\Exceptions\RESTRequestException;
use Automattic\WCShipping\Utils;
class ShipmentsService {
const META_KEY = '_wcshipping-shipments';
/**
* @var WC_Connect_Service_Settings_Store
*/
protected $settings_store;
/**
* @var ShipmentDataValidator
*/
protected $validator;
/**
* Constructor.
*
* @param WC_Connect_Service_Settings_Store $settings_store Settings store instance.
* @param ShipmentDataValidator|null $validator Optional validator instance for testing.
*/
public function __construct( WC_Connect_Service_Settings_Store $settings_store, ?ShipmentDataValidator $validator = null ) {
$this->settings_store = $settings_store;
$this->validator = $validator ?? new ShipmentDataValidator();
}
/**
* Update shipments on an order.
*
* @param int $order_id The WC order ID.
* @param array $raw_shipments The shipments sent with the api call.
* @param array $shipment_ids_to_update The shipment ID map to update.
* @return RESTRequestException|int
*
* @throws RESTRequestException Will throw an error if the order is not found.
*/
public function update_order_shipments( int $order_id, array $raw_shipments, array $shipment_ids_to_update = array() ) {
/**
* @var WC_Order $order
*/
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
/*
* We have to escape an exceptions output in case it's not caught internally.
*
* @link https://github.com/WordPress/WordPress-Coding-Standards/issues/884
*/
throw new RESTRequestException( esc_html__( 'Order not found when updating order shipments ', 'woocommerce-shipping' ) );
}
$shipments = $this->create_shipments_model( $raw_shipments, $order );
$order->update_meta_data( self::META_KEY, $shipments );
if ( ! empty( $shipment_ids_to_update ) ) {
$order_labels = $this->settings_store->get_label_order_meta_data( $order_id );
foreach ( $order_labels as &$label ) {
if ( isset( $shipment_ids_to_update[ $label['id'] ] ) ) {
$label['id'] = $shipment_ids_to_update[ $label['id'] ];
}
}
$order->update_meta_data( 'wcshipping_labels', $order_labels );
}
return $order->save();
}
/**
* Get the order shipments JSON.
*
* If shipments are already stored as order meta, returns a JSON encoded object of those shipments.
* Otherwise, it generates shipments (and flag whether fallback was needed) and then returns a JSON encoded object.
*
* @param int $order_id The WC order ID.
* @return array {
* @type array $shipments The shipments.
* @type array $autogenerated_from_labels The autogenerated from labels.
* }
*/
public function get_order_shipments_data( $order_id ): array {
/**
* @var WC_Order $order
*/
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
return array(
'shipments' => array(),
'autogenerated_from_labels' => array(),
);
}
$raw_shipments = $order->get_meta( self::META_KEY );
if ( ! empty( $raw_shipments ) ) {
$shipments = $this->create_shipments_model( $raw_shipments, $order );
return array(
'shipments' => $shipments,
'autogenerated_from_labels' => array(),
);
}
$labels = $this->settings_store->get_label_order_meta_data( $order->get_id() );
// Filter labels that are refunded or ones that errored while purchase
// when auto-generating shipments to prevent
// creation of extra shipments from refunded labels.
$valid_labels = array_filter(
$labels,
function ( $label ) {
return empty( $label['refund'] ) && $label['status'] !== 'PURCHASE_ERROR';
}
);
/**
* We normally end up generating shipments from labels when we have only once shipment,
* that's where shipment split flow is never used and historically no shipment meta is set.
* So we return the shipment built from order items and the autogenerated from labels array
* with the index of the label that was used to generate the shipment.
*/
if ( count( $valid_labels ) === 1 ) {
return array(
'shipments' => array( self::build_shipment_from_order_items( $order ) ),
'autogenerated_from_labels' => array( 0 ),
);
}
$generator = new ShipmentFromLabelGenerator( $order );
return $generator->generate_shipments( $valid_labels );
}
/**
* Create a Shipments model from raw data.
*
* First attempts to create the model directly. If an InvalidArgumentException
* is thrown (indicating corrupted data like null item IDs), runs the repair
* logic and retries. This avoids the overhead of validation for healthy orders.
*
* @param array $raw_shipments Raw shipments data.
* @param WC_Order $order The WooCommerce order object.
* @return array The shipments data as an array.
*
* @throws RESTRequestException If model creation fails even after repair.
*/
protected function create_shipments_model( array $raw_shipments, WC_Order $order ): array {
try {
$shipments_model = new Shipments( $raw_shipments );
return $shipments_model->to_array();
} catch ( InvalidArgumentException $e ) {
// Data is corrupted (e.g., null item IDs). Attempt repair and retry.
$repaired_shipments = $this->validator->validate_and_repair( $raw_shipments, $order );
try {
$shipments_model = new Shipments( $repaired_shipments );
return $shipments_model->to_array();
} catch ( Exception $retry_exception ) {
throw new RESTRequestException(
sprintf(
/* translators: %s: Reason for the failure in creating the data model */
esc_html__( 'Shipment model creation failed after repair attempt: %s', 'woocommerce-shipping' ),
esc_html( $retry_exception->getMessage() )
)
);
}
} catch ( Exception $e ) {
throw new RESTRequestException(
sprintf(
/* translators: %s: Reason for the failure in creating the data model */
esc_html__( 'Shipment model creation failed with error: %s', 'woocommerce-shipping' ),
esc_html( $e->getMessage() )
)
);
}
}
/**
* Build a shipment from order items.
*
* @param WC_Order $order Order object.
* @return array
*/
public static function build_shipment_from_order_items( $order ) {
$order_products = array();
foreach ( $order->get_items() as $item_id => $item ) {
$product = $item->get_product();
if ( ! $product instanceof \WC_Product ) {
continue;
}
if ( ! $product->needs_shipping() ) {
continue;
}
$customs_info = Utils::get_product_customs_data( $product );
if ( $customs_info ) {
$product_meta['customs_info'] = $customs_info;
}
$subItems = array();
if ( $item->get_quantity() > 1 ) {
foreach ( array_fill( 0, $item->get_quantity(), null ) as $index => $_ ) {
$subItems[] = $item_id . '-sub-' . $index;
}
}
$line_item = array(
'id' => $item_id,
'subItems' => $subItems,
);
$order_products[] = $line_item;
}
return $order_products;
}
}