*/ private $page_cache = array(); /** * Register hooks once (Free owns this surface). * * @return void */ public function register() { // Runs after WP_List_Table priority 0 → get_columns() → manage_product_posts_columns // (where Woo may inject cogs_value), so native presence is observable here. add_filter( 'manage_edit-product_columns', array( $this, 'add_column' ), 20 ); add_action( 'manage_product_posts_custom_column', array( $this, 'render_column' ), 10, 2 ); add_action( 'restrict_manage_posts', array( $this, 'render_filter' ), 20, 2 ); add_action( 'pre_get_posts', array( $this, 'apply_filter' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); } /** * Keep the COGS column readable on the dense product list table. * * CSS is enqueued whenever this screen can show the MarginBeacon column. * Column presence is decided later on manage_edit-product_columns; unused * selectors are harmless when the native Woo column is used alone. * * @param string $hook Current admin page hook. * @return void */ public function enqueue_styles( $hook ) { if ( 'edit.php' !== $hook ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Screen detection only. $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : ''; if ( 'product' !== $post_type || ! $this->user_can_view() ) { return; } $css = ' .wp-list-table.widefat .column-' . self::COLUMN_KEY . ' { width: 8.5em; min-width: 8.5em; max-width: 8.5em; vertical-align: middle; } .wp-list-table.widefat .column-' . self::COLUMN_KEY . ' .aksmb-cogs-cell { display: block; max-width: 100%; line-height: 1.35; overflow: hidden; } .wp-list-table.widefat .column-' . self::COLUMN_KEY . ' .aksmb-cogs-cell__amount { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } .wp-list-table.widefat .column-' . self::COLUMN_KEY . ' .aksmb-cogs-cell__source { display: block; max-width: 100%; margin-top: 2px; color: #646970; font-size: 11px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } '; wp_register_style( 'aksmb-product-list-cogs', false, array(), AKSMB_VERSION ); wp_enqueue_style( 'aksmb-product-list-cogs' ); wp_add_inline_style( 'aksmb-product-list-cogs', preg_replace( '/\s+/', ' ', trim( $css ) ) ); } /** * Whether the WooCommerce native Cost column is already in the column map. * * @param array $columns Columns. * @return bool */ public function columns_include_native_cogs( $columns ) { return is_array( $columns ) && array_key_exists( self::NATIVE_COLUMN_KEY, $columns ); } /** * Whether MarginBeacon should register its fallback/primary COGS column. * * @param array $columns Columns after Woo (and earlier filters) have run. * @return bool */ public function should_register_marginbeacon_column( $columns ) { if ( ! $this->user_can_view() ) { return false; } $canonical = new CanonicalCostSource(); if ( $canonical->is_woocommerce() && $this->columns_include_native_cogs( $columns ) ) { return false; } return true; } /** * @param array $columns Columns. * @return array */ public function add_column( $columns ) { if ( ! is_array( $columns ) ) { return $columns; } if ( ! $this->should_register_marginbeacon_column( $columns ) ) { return $columns; } $new = array(); foreach ( $columns as $key => $label ) { $new[ $key ] = $label; if ( 'price' === $key ) { $new[ self::COLUMN_KEY ] = __( 'COGS', 'marginbeacon' ); } } if ( ! isset( $new[ self::COLUMN_KEY ] ) ) { $new[ self::COLUMN_KEY ] = __( 'COGS', 'marginbeacon' ); } return $new; } /** * @param string $column Column key. * @param int $post_id Product ID. * @return void */ public function render_column( $column, $post_id ) { if ( self::COLUMN_KEY !== $column || ! $this->user_can_view() ) { return; } $row = $this->resolve_row( (int) $post_id ); echo ''; printf( '%s', esc_attr( $row['full'] ), esc_html( $row['display'] ) ); if ( '' !== $row['source_label'] && __( 'Missing', 'marginbeacon' ) !== $row['full'] ) { echo '' . esc_html( $row['source_label'] ) . ''; } echo ''; } /** * @param string $post_type Post type. * @param string $which Top/bottom. * @return void */ public function render_filter( $post_type, $which = '' ) { unset( $which ); if ( 'product' !== $post_type || ! $this->user_can_view() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $current = isset( $_GET[ self::FILTER_KEY ] ) ? sanitize_key( wp_unslash( $_GET[ self::FILTER_KEY ] ) ) : ''; ?> is_main_query() || ! $this->user_can_view() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : ''; if ( 'product' !== $post_type ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $filter = isset( $_GET[ self::FILTER_KEY ] ) ? sanitize_key( wp_unslash( $_GET[ self::FILTER_KEY ] ) ) : ''; if ( ! in_array( $filter, array( 'missing', 'has' ), true ) ) { return; } $canonical = new CanonicalCostSource(); $meta_key = $canonical->is_woocommerce() ? '_cogs_total_value' : Constants::META_MANUAL_COST; $meta_query = (array) $query->get( 'meta_query' ); if ( 'has' === $filter ) { $meta_query[] = array( 'key' => $meta_key, 'compare' => 'EXISTS', ); $meta_query[] = array( 'key' => $meta_key, 'value' => '', 'compare' => '!=', ); } else { $meta_query[] = array( 'relation' => 'OR', array( 'key' => $meta_key, 'compare' => 'NOT EXISTS', ), array( 'key' => $meta_key, 'value' => '', 'compare' => '=', ), ); } $query->set( 'meta_query', $meta_query ); } /** * @param int $post_id Product ID. * @return array{display:string,full:string,source_label:string} */ private function resolve_row( $post_id ) { if ( isset( $this->page_cache[ $post_id ] ) ) { return $this->page_cache[ $post_id ]; } $canonical = new CanonicalCostSource(); $resolver = new CostResolver(); $resolved = $resolver->resolve_catalog( $post_id, 0 ); if ( ! $resolved->is_resolved() || ! $resolved->amount() ) { $missing = __( 'Missing', 'marginbeacon' ); $row = array( 'display' => $missing, 'full' => $missing, 'source_label' => '', ); } else { $full = $this->format_amount( $resolved->amount()->to_decimal_string() ); $display = $this->truncate_display( $full ); $row = array( 'display' => $display, 'full' => $full, 'source_label' => $canonical->label(), ); } $this->page_cache[ $post_id ] = $row; return $row; } /** * Format a decimal cost for list display without float overflow. * * Casting extreme DECIMAL values through (float) produces garbage currency * strings that blow up the products table layout. * * @param string $decimal Tax-exclusive unit cost. * @return string */ private function format_amount( $decimal ) { $decimal = (string) $decimal; $safe_for_float = function_exists( 'bccomp' ) ? bccomp( $decimal, '0', 6 ) >= 0 && bccomp( $decimal, '1000000000', 6 ) < 0 : (bool) preg_match( '/^\d{1,9}(?:\.\d+)?$/', $decimal ); if ( $safe_for_float && function_exists( 'wc_price' ) ) { return wp_strip_all_tags( wc_price( (float) $decimal ) ); } $symbol = function_exists( 'get_woocommerce_currency_symbol' ) ? html_entity_decode( (string) get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8' ) : ''; return $symbol . $decimal; } /** * Cap visible characters; full value remains available via title tooltip. * * @param string $text Display text. * @return string */ private function truncate_display( $text ) { $text = (string) $text; $max = self::DISPLAY_MAX_CHARS; if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { if ( mb_strlen( $text ) <= $max ) { return $text; } return mb_substr( $text, 0, $max - 1 ) . '…'; } if ( strlen( $text ) <= $max ) { return $text; } return substr( $text, 0, $max - 1 ) . '…'; } /** * Capability-based: anyone who can edit products. * * @return bool */ private function user_can_view() { return current_user_can( 'edit_products' ); } }