[family, size in the family's smallest unit]. */ public const UNITS = array( 'g' => array( 'mass', 1 ), 'kg' => array( 'mass', 1000 ), 'ml' => array( 'volume', 1 ), 'l' => array( 'volume', 1000 ), 'pc' => array( 'count', 1 ), 'm' => array( 'length', 1 ) ); /** Base choice => [quantity, unit]. */ public const BASES = array( '100g' => array( 100, 'g' ), '1kg' => array( 1, 'kg' ), '100ml' => array( 100, 'ml' ), '1l' => array( 1, 'l' ), '1pc' => array( 1, 'pc' ), '1m' => array( 1, 'm' ) ); /** "Automatic" base per family: the legal units of Turkish and EU unit pricing (kilogram, litre, piece, metre). */ private const DEFAULT_BASE = array( 'mass' => '1kg', 'volume' => '1l', 'count' => '1pc', 'length' => '1m' ); /** UN/CEFACT common codes (schema.org unitCode) and Google Merchant Center unit names. */ private const CEFACT = array( 'g' => 'GRM', 'kg' => 'KGM', 'ml' => 'MLT', 'l' => 'LTR', 'pc' => 'C62', 'm' => 'MTR' ); private const GOOGLE = array( 'g' => 'g', 'kg' => 'kg', 'ml' => 'ml', 'l' => 'l', 'pc' => 'ct', 'm' => 'm' ); private const MAX_QTY = 1000000; public static function hooks(): void { add_action( 'woocommerce_product_options_general_product_data', array( __CLASS__, 'fields' ) ); add_action( 'woocommerce_admin_process_product_object', array( __CLASS__, 'save' ) ); add_action( 'woocommerce_variation_options_pricing', array( __CLASS__, 'variation_fields' ), 20, 3 ); add_action( 'woocommerce_admin_process_variation_object', array( __CLASS__, 'save_variation' ), 10, 2 ); add_filter( 'storeruler_unit_measure', array( __CLASS__, 'measure_filter' ), 10, 2 ); add_filter( 'woocommerce_structured_data_product_offer', array( __CLASS__, 'schema_offer' ), 20, 2 ); } /* ---------- Reading ---------- */ /** * The measure of a product or option, or null: qty, unit (the net quantity), base_qty, base_unit. An option's empty * fields take the product's values. A base of another kind than the unit (litres for grams) falls back to the automatic one. */ public static function measure( \WC_Product $product ): ?array { $own = self::stored( $product ); if ( $product->is_type( 'variation' ) && $product->get_parent_id() && in_array( '', $own, true ) ) { // The product's saved values, read directly: building the whole product for three fields costs a card its speed. $parent = $product->get_parent_id(); $from = array( 'qty' => (string) get_post_meta( $parent, self::QTY, true ), 'unit' => (string) get_post_meta( $parent, self::UNIT, true ), 'base' => (string) get_post_meta( $parent, self::BASE, true ) ); foreach ( $own as $key => $value ) { if ( $value === '' ) { $own[ $key ] = $from[ $key ]; } } } $qty = is_numeric( $own['qty'] ) ? (float) $own['qty'] : 0.0; if ( $qty <= 0 || $qty > self::MAX_QTY || ! isset( self::UNITS[ $own['unit'] ] ) ) { return null; } $family = self::UNITS[ $own['unit'] ][0]; $base = isset( self::BASES[ $own['base'] ] ) && self::UNITS[ self::BASES[ $own['base'] ][1] ][0] === $family ? $own['base'] : self::DEFAULT_BASE[ $family ]; return array( 'qty' => $qty, 'unit' => $own['unit'], 'base_qty' => (float) self::BASES[ $base ][0], 'base_unit' => self::BASES[ $base ][1] ); } /** The three stored values as entered ('' when empty). */ private static function stored( \WC_Product $product ): array { return array( 'qty' => (string) $product->get_meta( self::QTY ), 'unit' => (string) $product->get_meta( self::UNIT ), 'base' => (string) $product->get_meta( self::BASE ) ); } /** Filter storeruler_unit_measure ($measure, $product): the theme's unit price line. */ public static function measure_filter( $measure, $product = null ) { return $product instanceof \WC_Product ? self::measure( $product ) : $measure; } /** "250g", "1.5kg": Google's form (a number and a unit, a point for decimals). */ private static function google_value( float $qty, string $unit ): string { return rtrim( rtrim( number_format( $qty, 3, '.', '' ), '0' ), '.' ) . self::GOOGLE[ $unit ]; } /** The feed attributes for a product or option: unit_pricing_measure and unit_pricing_base_measure, or none. */ public static function feed_measures( \WC_Product $product ): array { $measure = self::measure( $product ); if ( ! $measure ) { return array(); } return array( 'unit_pricing_measure' => self::google_value( $measure['qty'], $measure['unit'] ), 'unit_pricing_base_measure' => self::google_value( $measure['base_qty'], $measure['base_unit'] ) ); } /** * Structured data: the offer's current price specification says what quantity that price buys and what the unit price is * given for (Google's merchant listing "unit pricing measure"): referenceQuantity = the base, valueReference = the net * quantity. Simple products only (a variable product's AggregateOffer has no single quantity). */ public static function schema_offer( $offer, $product = null ) { if ( ! is_array( $offer ) || ! $product instanceof \WC_Product || ! $product->is_type( 'simple' ) || ( $offer['@type'] ?? '' ) !== 'Offer' || empty( $offer['priceSpecification'] ) || ! is_array( $offer['priceSpecification'] ) ) { return $offer; } $measure = self::measure( $product ); if ( ! $measure ) { return $offer; } $reference = array( '@type' => 'QuantitativeValue', 'value' => $measure['base_qty'], 'unitCode' => self::CEFACT[ $measure['base_unit'] ], 'valueReference' => array( '@type' => 'QuantitativeValue', 'value' => $measure['qty'], 'unitCode' => self::CEFACT[ $measure['unit'] ] ), ); foreach ( $offer['priceSpecification'] as $i => $spec ) { // The price in force only: a struck-through list price (priceType) is not what the quantity costs now. if ( is_array( $spec ) && ( $spec['@type'] ?? '' ) === 'UnitPriceSpecification' && empty( $spec['priceType'] ) ) { $offer['priceSpecification'][ $i ]['referenceQuantity'] = $reference; } } return $offer; } /* ---------- Product screen ---------- */ private static function unit_choices(): array { return array( 'g' => I18n::t( 'g (gram)' ), 'kg' => I18n::t( 'kg (kilogram)' ), 'ml' => I18n::t( 'ml (millilitre)' ), 'l' => I18n::t( 'l (litre)' ), 'pc' => I18n::t( 'pieces' ), 'm' => I18n::t( 'm (metre)' ) ); } private static function base_choices(): array { return array( '' => I18n::t( 'Automatic (1 kg, 1 l, 1 piece or 1 m)' ), '100g' => '100 g', '1kg' => '1 kg', '100ml' => '100 ml', '1l' => '1 l', '1pc' => I18n::t( '1 piece' ), '1m' => '1 m' ); } /** The choices of the product screen, for StoreRuler's own Products workspace: units, bases (with what they mean) and the automatic base per family. */ public static function choices(): array { return array( 'units'=>self::unit_choices(), 'bases'=>self::base_choices(), 'sizes'=>self::UNITS, 'base_values'=>self::BASES, 'auto'=>self::DEFAULT_BASE, 'max'=>self::MAX_QTY ); } public static function fields(): void { global $product_object; $product = $product_object instanceof \WC_Product ? $product_object : null; $value = static fn( string $key ): string => $product ? (string) $product->get_meta( $key ) : ''; echo '
'; } /** Saved with the product (WooCommerce checked the nonce and the user's rights before this action). */ public static function save( $product ): void { if ( ! $product instanceof \WC_Product ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Missing -- WooCommerce's product save verified the nonce. $pick = static fn( string $key ) => isset( $_POST[ $key ] ) && is_scalar( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; self::apply( $product, $pick( 'sr_net_qty' ), $pick( 'sr_net_unit' ), $pick( 'sr_unit_base' ), false ); // phpcs:enable } public static function variation_fields( $loop, $variation_data, $variation ): void { $product = wc_get_product( $variation instanceof \WP_Post ? $variation->ID : 0 ); if ( ! $product instanceof \WC_Product ) { return; } $loop = (int) $loop; echo '