self::decimal( $match[1] ), 'currency' => $currency, ); } private static function decimal( string $number ): string { $parts = explode( '.', $number, 2 ); $whole = ltrim( $parts[0], '0' ); $fraction = rtrim( $parts[1] ?? '', '0' ); return ( '' === $whole ? '0' : $whole ) . ( '' !== $fraction ? '.' . $fraction : '' ); } public static function effective_price( array $row, int $now ): ?array { $base = self::money( $row['price'], $row['currency'] ); if ( ! $base || '' === $row['sale_price'] ) { return $base; } $sale = self::money( $row['sale_price'], '' !== $row['currency'] ? $row['currency'] : $base['currency'] ); if ( ! $sale || $sale['currency'] !== $base['currency'] || (float) $sale['amount'] >= (float) $base['amount'] ) { return null; } if ( '' === $row['sale_price_effective_date'] ) { return $sale; } $dates = explode( '/', $row['sale_price_effective_date'] ); if ( 2 !== count( $dates ) ) { return null; } $times = array(); foreach ( $dates as $date ) { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:?\d{2})$/D', $date ) ) { return null; } try { $parsed = new \DateTimeImmutable( $date ); $errors = \DateTimeImmutable::getLastErrors(); if ( is_array( $errors ) && ( $errors['warning_count'] > 0 || $errors['error_count'] > 0 ) ) { return null; } $times[] = $parsed->getTimestamp(); } catch ( \Exception $exception ) { return null; } } if ( $times[0] >= $times[1] ) { return null; } return $now >= $times[0] && $now < $times[1] ? $sale : $base; } private static function finding( array &$result, string $code, string $level, string $message, string $fix ): void { $result['findings'][] = array( 'code' => $code, 'level' => $level, 'message' => $message, 'fix' => $fix, ); if ( 'error' === $level || 'checked' === $result['status'] ) { $result['status'] = $level; } } public function row( array $row, string $mode, bool $duplicate ): array { $result = array( 'id' => $row['id'], 'sku' => $row['sku'], 'product_id' => 0, 'status' => 'checked', 'findings' => array(), 'feed' => $row, 'woo' => array(), ); $price = self::effective_price( $row, time() ); $result['active_price'] = $price; if ( '' === $row['id'] ) { self::finding( $result, 'feed_id', 'error', __( 'Feed id is empty.', 'adparity-lite' ), __( 'Supply a unique Merchant item id for every row.', 'adparity-lite' ) ); } if ( $duplicate ) { self::finding( $result, 'duplicate_id', 'error', __( 'This Merchant item id occurs more than once.', 'adparity-lite' ), __( 'Give each product variation a unique feed id.', 'adparity-lite' ) ); } if ( ! $price ) { self::finding( $result, 'feed_price', 'error', __( 'Price, currency or sale date is missing or invalid.', 'adparity-lite' ), __( 'Use 19.90 EUR or a separate currency column. Sale price must be lower; dates need ISO 8601 timezones and start/end.', 'adparity-lite' ) ); } if ( ! in_array( $row['availability'], array( 'in_stock', 'out_of_stock', 'preorder', 'backorder' ), true ) ) { self::finding( $result, 'feed_availability', 'error', __( 'Feed availability is missing or invalid.', 'adparity-lite' ), __( 'Use in_stock, out_of_stock, preorder or backorder.', 'adparity-lite' ) ); } $key = 'sku' === $mode ? ( '' !== $row['sku'] ? $row['sku'] : $row['id'] ) : $row['id']; $id = 'sku' === $mode ? ( '' !== $key ? wc_get_product_id_by_sku( $key ) : 0 ) : ( preg_match( '/^[1-9]\d{0,9}$/D', $key ) ? (int) $key : 0 ); $product = $id ? wc_get_product( $id ) : false; if ( ! $product ) { self::finding( $result, 'unmatched', 'error', __( 'No WooCommerce product matches the selected identifier mode.', 'adparity-lite' ), __( 'Choose WooCommerce ID when id contains internal product IDs; otherwise choose SKU and supply sku or use the SKU as id.', 'adparity-lite' ) ); return $result; } $result['product_id'] = $product->get_id(); if ( ! $product->is_type( 'simple' ) ) { self::finding( $result, 'unsupported_type', 'review', __( "This product type is outside Lite's simple-product checks.", 'adparity-lite' ), __( 'Review variations and other product types manually. They are not counted as verified by Lite.', 'adparity-lite' ) ); return $result; } if ( 'publish' !== $product->get_status() ) { self::finding( $result, 'not_public', 'error', __( 'The product is not published.', 'adparity-lite' ), __( 'Remove unpublished items from the feed or publish the intended product.', 'adparity-lite' ) ); return $result; } if ( ( '' !== $row['sku'] && $row['sku'] !== $product->get_sku() ) || ( 'sku' === $mode && $key !== $product->get_sku() ) ) { self::finding( $result, 'sku', 'error', __( 'The feed SKU disagrees with WooCommerce.', 'adparity-lite' ), __( 'Correct the feed SKU mapping; do not map several variants to the parent SKU.', 'adparity-lite' ) ); } $raw_price = $product->get_price(); $display_price = '' === $raw_price ? '' : wc_format_decimal( wc_get_price_to_display( $product ), wc_get_price_decimals() ); $stock = $product->is_in_stock() ? ( $product->is_on_backorder( 1 ) ? 'backorder' : 'in_stock' ) : 'out_of_stock'; $canonical = $product->get_permalink(); $result['woo'] = array( 'sku' => $product->get_sku(), 'price' => $display_price, 'currency' => get_woocommerce_currency(), 'availability' => $stock, 'url' => $canonical, ); if ( '' === $display_price ) { self::finding( $result, 'woo_price', 'error', __( 'WooCommerce has no price for this product.', 'adparity-lite' ), __( 'Set a product price before advertising it.', 'adparity-lite' ) ); } elseif ( $price && self::decimal( $display_price ) !== $price['amount'] ) { self::finding( $result, 'price', 'error', __( 'The active feed price differs from the store display price.', 'adparity-lite' ), __( 'Refresh the feed and check sale dates and the store tax display setting.', 'adparity-lite' ) ); } if ( $price && $price['currency'] !== $result['woo']['currency'] ) { self::finding( $result, 'currency', 'error', __( 'The feed currency differs from the store currency.', 'adparity-lite' ), __( 'Use a feed for the store base currency. Contextual multi-currency feeds are outside this version.', 'adparity-lite' ) ); } if ( $row['availability'] !== $stock ) { self::finding( $result, 'availability', 'preorder' === $row['availability'] ? 'review' : 'error', __( 'The feed availability differs from WooCommerce.', 'adparity-lite' ), __( 'Refresh stock values and check backorders. Preorder extensions need manual review.', 'adparity-lite' ) ); } $link_ok = ProductLink::same_origin( $canonical, home_url( '/' ) ) && ProductLink::same_product( $row['link'], $canonical ); if ( ! $link_ok ) { self::finding( $result, 'link', 'error', __( 'The feed link does not select the canonical product.', 'adparity-lite' ), __( 'Use the canonical product URL. Only tracking parameters may differ.', 'adparity-lite' ) ); } return $result; } }