*/
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' );
}
}