prices = new EffectivePriceResolver(); $this->calculator = new WooCalculationAdapter(); $this->costs = new CostResolver(); $this->classifier = new SeverityClassifier(); $this->repo = $repo ? $repo : new FindingsRepository(); } /** * Execute preflight for a coupon ID. * * @param int $coupon_id Coupon post ID. * @param array $options Optional: scan_id, evaluation_scope, complete_scan, type. * @return array */ public function run( $coupon_id, array $options = array() ) { $coupon_id = (int) $coupon_id; $coupon = new \WC_Coupon( $coupon_id ); if ( ! $coupon->get_id() ) { return array( 'success' => false, 'error' => __( 'Coupon not found.', 'marginbeacon' ), 'code' => 'MB_COUPON_NOT_FOUND', ); } // Hard-invalid coupons (expired/inactive) are terminal before any full-price classification. $precheck = ( new CouponSimulationValidator() )->validate_for_simulation( $coupon, null ); if ( empty( $precheck['valid'] ) ) { $code = (string) $precheck['code']; if ( in_array( $code, array( 'MB_COUPON_EXPIRED', 'MB_COUPON_INACTIVE', 'MB_COUPON_NOT_FOUND' ), true ) ) { return array( 'success' => false, 'error' => sprintf( /* translators: %s: validation code */ __( 'Coupon rejected for simulation (%s). No full-price findings were generated.', 'marginbeacon' ), $code ), 'fidelity' => CalculationFidelity::REJECTED, 'code' => $code, ); } } $scope = isset( $options['evaluation_scope'] ) ? (string) $options['evaluation_scope'] : 'selected_free'; $complete_scan = ! array_key_exists( 'complete_scan', $options ) || (bool) $options['complete_scan']; $existing_id = isset( $options['scan_id'] ) ? (int) $options['scan_id'] : 0; $is_fixed_cart = $coupon->is_type( 'fixed_cart' ); $threshold_resolver = new ThresholdResolver(); $thresholds = $threshold_resolver->resolve_global()->to_array(); $currency = function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : ''; $targets = array(); $parent_population = $this->published_product_count(); $catalog_population = $parent_population; $evaluated_population = 0; $catalog_partial = false; $pro_basket_used = false; $fixed_cart_free = false; if ( $existing_id > 0 ) { $scan_id = $existing_id; } else { $scan_id = $this->repo->create_scan( array( 'type' => isset( $options['type'] ) ? (string) $options['type'] : 'selected', 'status' => 'running', 'evaluation_scope' => $scope, 'input_revision' => $this->coupon_revision( $coupon ), 'cursor_json' => wp_json_encode( array( 'coupon_id' => $coupon_id, 'coupon_code' => $coupon->get_code(), ) ), 'started_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); if ( $scan_id <= 0 ) { return array( 'success' => false, 'error' => __( 'Failed to create scan row.', 'marginbeacon' ), 'code' => 'MB_SCAN_CREATE_FAIL', ); } } $counts = array( 'processed' => 0, 'skipped' => 0, 'critical' => 0, 'high' => 0, 'warning' => 0, 'safe' => 0, 'unknown' => 0, ); $worst = null; $findings = array(); $qty = 1; $fidelity = $this->calculator->is_available() ? 'native' : 'approximated'; if ( $is_fixed_cart ) { /** * Pro may supply representative multi-line basket preflight. * Absence of Pro / invalid entitlement must fall through to Free * single-line fixed_cart baseline — never reject on entitlement alone. * * @param array|null $result Prior result. * @param array $context Preflight context. */ $basket_result = apply_filters( 'aksmb_run_fixed_cart_preflight', null, array( 'scan_id' => $scan_id, 'coupon' => $coupon, 'coupon_id' => $coupon_id, 'scope' => $scope, 'threshold_resolver' => $threshold_resolver, 'thresholds' => $thresholds, 'currency' => $currency, 'fidelity' => $fidelity, ) ); if ( is_array( $basket_result ) && isset( $basket_result['findings'] ) ) { $findings = $basket_result['findings']; $counts = isset( $basket_result['counts'] ) && is_array( $basket_result['counts'] ) ? $basket_result['counts'] : $counts; $worst = isset( $basket_result['worst'] ) ? $basket_result['worst'] : $worst; $fidelity = isset( $basket_result['fidelity'] ) ? (string) $basket_result['fidelity'] : $fidelity; if ( isset( $counts['processed'] ) ) { $evaluated_population = (int) $counts['processed']; } $pro_basket_used = true; } else { $fixed_cart_free = true; } } if ( ! $pro_basket_used ) { $targets = $this->collect_eligible_products( $coupon ); $evaluated_population = count( $targets ); $catalog_partial = $parent_population > self::CATALOG_CAP || $evaluated_population >= self::TARGET_CAP; if ( $fixed_cart_free ) { // Single-line Free scope does not claim Pro representative multi-line coverage. $catalog_partial = true; } foreach ( $targets as $target ) { $product_id = (int) $target['product_id']; $variation_id = (int) $target['variation_id']; $product = $target['product']; ++$counts['processed']; $price = $this->prices->resolve( $product ); if ( null === $price['amount'] ) { $row = $this->build_finding_row( $scan_id, $coupon, $product_id, $variation_id, Severity::UNKNOWN, null, null, null, $thresholds, $currency, $price['reason_code'] ? $price['reason_code'] : 'MB_PRICE_UNRESOLVED', array( 'on_sale' => $price['on_sale'] ), 'missing', 'none', $scope ); $findings[] = $row; ++$counts['unknown']; $worst = Severity::worst( $worst, Severity::UNKNOWN ); continue; } $unit_price = $price['amount']; // qty=1 unit scenario $alloc = $this->calculator->allocate_single_line( $coupon, $product, $unit_price, $qty ); $fidelity = CalculationFidelity::merge( $fidelity, $alloc['fidelity'] ); if ( CalculationFidelity::is_rejected( $alloc['fidelity'] ) ) { ++$counts['unknown']; $worst = Severity::worst( $worst, Severity::UNKNOWN ); $parent_id = $variation_id > 0 ? (int) $product->get_parent_id() : $product_id; $findings[] = $this->build_finding_row( $scan_id, $coupon, $parent_id > 0 ? $parent_id : $product_id, $variation_id, Severity::UNKNOWN, null, null, null, $thresholds, $currency, 'MB_COUPON_REJECTED', array( 'calculation_fidelity' => CalculationFidelity::REJECTED, 'allocator_note' => $alloc['note'], 'quantity' => $qty, 'rejected' => true, ), 'missing', 'none', $scope ); continue; } $discount = $alloc['discount']; $revenue = MoneyMath::sub( $unit_price, $discount ); $cost_res = $this->costs->resolve_catalog( $variation_id > 0 ? (int) $product->get_parent_id() : $product_id, $variation_id ); $parent_id = $variation_id > 0 ? (int) $product->get_parent_id() : $product_id; $cogs = $cost_res->is_resolved() ? $cost_res->amount() : null; $complete = $cost_res->is_resolved(); $cat_ids = $this->product_category_ids( $product ); $result = $threshold_resolver->classify_for_categories( $revenue, $cogs, $cat_ids, $complete, $this->classifier ); $line_thresholds = isset( $result['thresholds'] ) && is_array( $result['thresholds'] ) ? $result['thresholds'] : $thresholds; $severity = $result['severity']; $counts[ $severity ] = isset( $counts[ $severity ] ) ? $counts[ $severity ] + 1 : 1; $worst = Severity::worst( $worst, $severity ); $reason = $result['reason_code']; $safe_label = null; if ( $coupon->get_free_shipping() ) { if ( Severity::SAFE === $severity ) { $safe_label = 'Product margin Safe — shipping cost excluded'; $reason = $reason ? $reason : 'MB013'; } } $row = $this->build_finding_row( $scan_id, $coupon, $parent_id > 0 ? $parent_id : $product_id, $variation_id, $severity, $revenue, $cogs, $result['gross_profit'], $line_thresholds, $currency, $reason, array( 'on_sale' => $price['on_sale'], 'price_basis' => $price['basis'], 'effective_unit_price' => $unit_price->to_decimal_string(), 'allocated_discount' => $discount->to_decimal_string(), 'calculation_fidelity' => $alloc['fidelity'], 'allocator_note' => $alloc['note'], 'severity_basis' => $result['severity_basis'], 'quantity' => $qty, 'shipping_cost_excluded' => (bool) $coupon->get_free_shipping(), 'safe_display_label' => $safe_label, 'category_refs' => ThresholdResolver::category_refs_payload( $result ), 'fixed_cart_scope' => $fixed_cart_free ? 'single_line_free' : null, ), $cost_res->confidence(), $cost_res->source(), $scope ); $findings[] = $row; } } // MB013: free-shipping coupons are merchandise-only; shipping cost is out of V1 scope. if ( $coupon->get_free_shipping() && ! CalculationFidelity::is_rejected( $fidelity ) ) { $findings[] = array( 'scan_id' => $scan_id, 'entity_type' => 'coupon', 'entity_id' => $coupon_id, 'rule_id' => 'MB013', 'rule_version' => AKSMB_FORMULA_VERSION, 'severity' => Severity::UNKNOWN, 'freshness' => Freshness::FRESH, 'revenue' => null, 'cogs' => null, 'gross_profit' => null, 'margin' => null, 'money_scale' => Constants::DEFAULT_MONEY_SCALE, 'severity_basis_json' => null, 'currency' => $currency, 'threshold_snapshot_json' => wp_json_encode( $thresholds ), 'cost_source' => null, 'confidence' => null, 'source_presence' => null, 'coverage_json' => wp_json_encode( array( 'kind' => 'configuration' ) ), 'evaluation_scope' => $scope, 'input_hash' => hash( 'sha256', 'MB013_' . $coupon_id . '_' . $scan_id ), 'details_json' => wp_json_encode( array( 'reason_code' => 'MB013', 'message' => 'Free-shipping coupon: shipping cost is outside MarginBeacon V1 scope and is not scored as financial severity.', 'safe_display_label' => 'Product margin Safe — shipping cost excluded', 'coupon_id' => $coupon_id, 'coupon_code' => $coupon->get_code(), 'coupon_discount_type' => (string) $coupon->get_discount_type(), 'coupon_amount' => (string) $coupon->get_amount(), ) ), 'created_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ); } foreach ( $findings as $finding ) { $fid = $this->repo->insert_finding( $finding ); if ( $fid <= 0 ) { $this->repo->update_scan_status( $scan_id, 'failed' ); return array( 'success' => false, 'scan_id' => $scan_id, 'error' => __( 'Failed to persist finding row.', 'marginbeacon' ), 'code' => 'MB_SCAN_FINDING_PERSIST', ); } } if ( $complete_scan ) { $rejected = CalculationFidelity::is_rejected( $fidelity ); $complete_ok = $this->repo->complete_scan( $scan_id, array( 'status' => $rejected ? 'failed' : 'completed', 'counts_json' => wp_json_encode( array_merge( $counts, array( 'calculation_fidelity' => $fidelity, 'adapter' => $this->calculator->is_available() ? 'woo_discounts' : 'fallback', 'coupon_id' => $coupon_id, 'coupon_code' => $coupon->get_code(), 'affected' => (int) $counts['processed'], 'basket_preflight' => ! empty( $counts['basket_preflight'] ), 'baskets_run' => isset( $counts['baskets_run'] ) ? (int) $counts['baskets_run'] : 0, 'catalog_cap' => self::CATALOG_CAP, 'target_cap' => self::TARGET_CAP, 'targets_evaluated' => $evaluated_population, 'parent_population' => $parent_population, 'evaluated_population' => $evaluated_population, 'catalog_population' => $parent_population, 'partial' => $catalog_partial, 'scan_status' => $rejected ? 'rejected' : ( $catalog_partial ? 'partial' : 'complete' ), 'rejected' => $rejected, 'fixed_cart_scope' => $pro_basket_used ? 'pro_representative_baskets' : ( $fixed_cart_free ? 'single_line_free' : null ), 'pro_scope_not_evaluated' => $fixed_cart_free, ) ) ), 'completed_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); if ( ! $complete_ok ) { throw new \RuntimeException( 'MarginBeacon preflight scan persistence failed: MB_SCAN_TERMINAL_PERSIST' ); } } if ( CalculationFidelity::is_rejected( $fidelity ) ) { return array( 'success' => false, 'scan_id' => $scan_id, 'coupon_id' => $coupon_id, 'coupon_code' => $coupon->get_code(), 'fidelity' => $fidelity, 'error' => __( 'Coupon rejected during simulation. Full-price findings were not treated as financial Safe.', 'marginbeacon' ), ); } return array( 'success' => true, 'scan_id' => $scan_id, 'coupon_id' => $coupon_id, 'coupon_code' => $coupon->get_code(), 'worst_severity' => $worst ? $worst : Severity::UNKNOWN, 'counts' => $counts, 'findings_count' => count( $findings ), 'formula_version' => AKSMB_FORMULA_VERSION, 'fidelity' => $fidelity, 'partial' => $catalog_partial, 'fixed_cart_scope' => $pro_basket_used ? 'pro_representative_baskets' : ( $fixed_cart_free ? 'single_line_free' : null ), 'pro_scope_not_evaluated' => $fixed_cart_free, ); } /** * Eligible simple products and purchasable variations. * * @param \WC_Coupon $coupon Coupon. * @return array */ private function collect_eligible_products( $coupon ) { $out = array(); $ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => self::CATALOG_CAP, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); foreach ( (array) $ids as $product_id ) { if ( count( $out ) >= self::TARGET_CAP ) { break; } $product = wc_get_product( $product_id ); if ( ! $product ) { continue; } if ( 'variable' === $product->get_type() ) { foreach ( $product->get_children() as $variation_id ) { if ( count( $out ) >= self::TARGET_CAP ) { break 2; } $variation = wc_get_product( $variation_id ); if ( ! $variation || ! $variation->is_purchasable() ) { continue; } if ( ! $this->coupon_applies_to_product( $coupon, $variation ) ) { continue; } $out[] = array( 'product_id' => (int) $product_id, 'variation_id' => (int) $variation_id, 'product' => $variation, ); } continue; } if ( in_array( $product->get_type(), array( 'external', 'grouped' ), true ) ) { continue; } if ( ! $product->is_purchasable() ) { continue; } if ( ! $this->coupon_applies_to_product( $coupon, $product ) ) { continue; } $out[] = array( 'product_id' => (int) $product_id, 'variation_id' => 0, 'product' => $product, ); } return $out; } /** * Product eligibility for preflight (percent / fixed_product / single-line fixed_cart Free). * * @param \WC_Coupon $coupon Coupon. * @param \WC_Product $product Product. * @return bool */ private function coupon_applies_to_product( $coupon, $product ) { if ( ! $coupon->is_type( 'fixed_cart' ) ) { return (bool) $coupon->is_valid_for_product( $product ); } // Woo's is_valid_for_product() deliberately rejects cart coupons. Each // Free target is a single-item cart, so apply the cart's inclusion and // exclusion constraints before handing allocation to WC_Discounts. if ( ! $coupon->is_valid_for_cart() ) { return false; } $parent_id = (int) $product->get_parent_id(); $ids = array( (int) $product->get_id(), $parent_id ); $cats = $product->get_category_ids(); if ( $parent_id > 0 ) { $parent = wc_get_product( $parent_id ); if ( $parent ) { $cats = array_merge( $cats, $parent->get_category_ids() ); } } if ( $coupon->get_product_ids() && ! array_intersect( $ids, $coupon->get_product_ids() ) ) { return false; } if ( $coupon->get_product_categories() && ! array_intersect( $cats, $coupon->get_product_categories() ) ) { return false; } if ( array_intersect( $ids, $coupon->get_excluded_product_ids() ) || array_intersect( $cats, $coupon->get_excluded_product_categories() ) ) { return false; } return ! ( $coupon->get_exclude_sale_items() && $product->is_on_sale() ); } /** * @param \WC_Product $product Product or variation. * @return int[] */ private function product_category_ids( $product ) { $cats = array_map( 'intval', (array) $product->get_category_ids() ); $parent = (int) $product->get_parent_id(); if ( $parent > 0 ) { $parent_p = wc_get_product( $parent ); if ( $parent_p ) { $cats = array_merge( $cats, array_map( 'intval', (array) $parent_p->get_category_ids() ) ); } } $cats = array_values( array_unique( array_filter( $cats ) ) ); sort( $cats, SORT_NUMERIC ); return $cats; } /** * @return int */ private function published_product_count() { $counts = wp_count_posts( 'product' ); return isset( $counts->publish ) ? (int) $counts->publish : 0; } /** * @param \WC_Coupon $coupon Coupon. * @return string */ private function coupon_revision( $coupon ) { return CouponMaterialHasher::hash_coupon( $coupon ); } /** * @return array */ private function build_finding_row( $scan_id, $coupon, $product_id, $variation_id, $severity, $revenue, $cogs, $gross, array $thresholds, $currency, $reason, array $details, $confidence, $cost_source, $evaluation_scope = 'selected_free' ) { $entity_id = $variation_id > 0 ? $variation_id : $product_id; $input = hash( 'sha256', wp_json_encode( array( 'scan' => $scan_id, 'coupon' => $coupon->get_id(), 'entity' => $entity_id, 'formula' => AKSMB_FORMULA_VERSION, 'severity' => $severity, ) ) ); return array( 'scan_id' => $scan_id, 'entity_type' => $variation_id > 0 ? 'variation' : 'product', 'entity_id' => $entity_id, 'rule_id' => 'MB_SELECTED_COUPON_UNIT', 'rule_version' => '1', 'severity' => $severity, 'freshness' => Freshness::FRESH, 'revenue' => $revenue ? $revenue->to_decimal_string() : null, 'cogs' => $cogs ? $cogs->to_decimal_string() : null, 'gross_profit' => $gross ? $gross->to_decimal_string() : null, 'margin' => ( $gross && $revenue && $revenue->is_positive() ) ? MarginRatio::decimal( $gross, $revenue ) : null, 'money_scale' => Constants::DEFAULT_MONEY_SCALE, 'severity_basis_json' => wp_json_encode( isset( $details['severity_basis'] ) ? $details['severity_basis'] : array() ), 'currency' => $currency, 'threshold_snapshot_json' => wp_json_encode( $thresholds ), 'cost_source' => $cost_source, 'confidence' => $confidence, 'source_presence' => $cogs ? 'explicit' : 'missing', 'coverage_json' => wp_json_encode( array( 'complete' => null !== $cogs, 'reason' => $reason, ) ), 'evaluation_scope' => (string) $evaluation_scope, 'input_hash' => $input, 'details_json' => wp_json_encode( array_merge( $details, $this->attribution_snapshot( $coupon, $product_id, $variation_id ), array( 'reason_code' => $reason, ) ) ), 'created_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ); } /** * Historical attribution snapshot for Results UI (code/type/amount/name). * * Does not affect severity, eligibility, or input_hash identity. * * @param \WC_Coupon $coupon Coupon. * @param int $product_id Parent/simple product ID. * @param int $variation_id Variation ID or 0. * @return array */ private function attribution_snapshot( $coupon, $product_id, $variation_id ) { $product_name = ''; $lookup_id = $variation_id > 0 ? (int) $variation_id : (int) $product_id; if ( $lookup_id > 0 && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $lookup_id ); if ( $product && method_exists( $product, 'get_name' ) ) { $product_name = (string) $product->get_name(); } } return array( 'coupon_id' => $coupon->get_id(), 'coupon_code' => $coupon->get_code(), 'coupon_discount_type' => (string) $coupon->get_discount_type(), 'coupon_amount' => (string) $coupon->get_amount(), 'product_id' => (int) $product_id, 'variation_id' => (int) $variation_id, 'product_name' => $product_name, ); } }