$contents A package's `contents`. * @return list */ public function build( array $contents ): array { return $this->pack( $this->units( $contents ) ); } /** * One entry per unit to ship (a line with quantity 3 gives three). * * @param array $contents * @return list */ public function units( array $contents ): array { $units = array(); foreach ( $contents as $line ) { $product = is_array( $line ) ? ( $line['data'] ?? null ) : null; $quantity = is_array( $line ) && is_numeric( $line['quantity'] ?? null ) ? (int) $line['quantity'] : 0; if ( ! $product instanceof \WC_Product || $quantity < 1 || ! $product->needs_shipping() ) { continue; } $weight = (float) wc_get_weight( (float) $product->get_weight(), 'kg' ); $sides = array( (float) wc_get_dimension( (float) $product->get_length(), 'cm' ), (float) wc_get_dimension( (float) $product->get_width(), 'cm' ), (float) wc_get_dimension( (float) $product->get_height(), 'cm' ), ); // A size with a side missing is no size at all; use the default box. if ( min( $sides ) <= 0 ) { $sides = $this->defaultSides; } $total = is_numeric( $line['line_total'] ?? null ) ? (float) $line['line_total'] : 0.0; $unit = array( 'weight' => $weight > 0 ? $weight : $this->defaultWeight, 'sides' => $sides, 'value' => max( 0.0, $total / $quantity ), ); for ( $i = 0; $i < $quantity; $i++ ) { $units[] = $unit; } } return $units; } /** * @param list $units * @return list */ public function pack( array $units ): array { if ( self::PER_ITEM === $this->mode ) { return array_map( static fn ( array $unit ): Parcel => new Parcel( $unit['weight'], $unit['sides'][0], $unit['sides'][1], $unit['sides'][2], $unit['value'] ), $units ); } return array_map( array( $this, 'box' ), self::splitByWeight( $units ) ); } /** * First-fit decreasing by weight into bins of at most 30 kg. A unit that * is over 30 kg on its own gets its own bin, and later fails the limits. * * @param list $units * @return list> */ public static function splitByWeight( array $units ): array { usort( $units, static fn ( array $a, array $b ): int => $b['weight'] <=> $a['weight'] ); $bins = array(); $weight = array(); foreach ( $units as $unit ) { foreach ( $bins as $i => $bin ) { if ( $weight[ $i ] + $unit['weight'] <= Parcel::MAX_WEIGHT_KG ) { $bins[ $i ][] = $unit; $weight[ $i ] += $unit['weight']; continue 2; } } $bins[] = array( $unit ); $weight[] = $unit['weight']; } return $bins; } /** * The default box, grown so each item fits lying flat and the total * volume fits. A rough box, on purpose: it can overstate the size, never * understate it, so the rate is never lower than what Canada Post bills. * * @param list $units */ private function box( array $units ): Parcel { $box = $this->defaultSides; rsort( $box ); $weight = 0.0; $value = 0.0; $volume = 0.0; foreach ( $units as $unit ) { $sides = $unit['sides']; rsort( $sides ); $box = array( max( $box[0], $sides[0] ), max( $box[1], $sides[1] ), max( $box[2], $sides[2] ) ); $weight += $unit['weight']; $value += $unit['value']; $volume += $sides[0] * $sides[1] * $sides[2]; } $base = $box[0] * $box[1]; if ( $base > 0 && $base * $box[2] < $volume ) { $box[2] = $volume / $base; } return new Parcel( $weight, $box[0], $box[1], $box[2], $value ); } }