store = $store ? $store : new ProductCostStore(); $this->canonical = $canonical ? $canonical : new CanonicalCostSource(); $this->scheduler = $scheduler; } /** * Catalog comparison summary using semantic presence (not HTML zeros). * * Reporting is intentionally bounded. A partial summary must never limit copy migration. * * @return array */ public function summarize() { $mb_found = 0; $woo_found = 0; $mb_only = 0; $woo_only = 0; $both_same = 0; $conflicts = 0; $missing_both = 0; $total = 0; $hit_cap = false; $parent_ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => CostCoverageService::CATALOG_CAP, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_meta_cache' => true, 'update_post_term_cache' => false, ) ); $hit_catalog_cap = is_array( $parent_ids ) && count( $parent_ids ) >= CostCoverageService::CATALOG_CAP; foreach ( $this->iterate_summary_targets_from_parents( is_array( $parent_ids ) ? $parent_ids : array() ) as $target ) { if ( $total >= CostCoverageService::TARGET_CAP ) { $hit_cap = true; break; } ++$total; $product_id = (int) $target['product_id']; $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null; $manual = $this->store->read_manual( $product_id ); $native = $this->store->read_native( $product ); $has_mb = null !== $manual && $manual->is_resolved(); $has_woo = null !== $native && $native->is_resolved(); if ( $has_mb ) { ++$mb_found; } if ( $has_woo ) { ++$woo_found; } if ( $has_mb && ! $has_woo ) { ++$mb_only; } elseif ( ! $has_mb && $has_woo ) { ++$woo_only; } elseif ( $has_mb && $has_woo ) { $mb_units = $manual->amount() ? $manual->amount()->units() : null; $woo_units = $native->amount() ? $native->amount()->units() : null; if ( null !== $mb_units && $mb_units === $woo_units ) { ++$both_same; } else { ++$conflicts; } } else { ++$missing_both; } } $summary = array( 'total' => $total, 'marginbeacon_costs' => $mb_found, 'woocommerce_cogs' => $woo_found, 'mb_only' => $mb_only, 'woo_only' => $woo_only, 'both_same' => $both_same, 'conflicts' => $conflicts, 'missing_both' => $missing_both, 'partial' => $hit_cap || $hit_catalog_cap, 'generated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ); $state = $this->canonical->migration_state(); $state['last_summary'] = $summary; if ( ! isset( $state['decision_status'] ) || 'completed' !== $state['decision_status'] ) { $state['decision_status'] = 'pending'; } update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); return $summary; } /** * Option 1: switch canonical source without copying. Legacy retained. * * @return array{success:bool,message:string} */ public function switch_without_copy() { if ( ! ( new CogsEnvironment() )->native_cogs_feature_enabled() ) { return array( 'success' => false, 'message' => __( 'WooCommerce COGS is not enabled. Enable it before switching.', 'marginbeacon' ), ); } $this->canonical->set( CanonicalCostSource::WOOCOMMERCE ); $state = $this->canonical->migration_state(); $state['last_mode'] = 'switch_only'; $state['last_result'] = $this->empty_totals(); $state['job'] = array( 'status' => self::JOB_COMPLETED, 'mode' => 'switch_only', ); update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); return array( 'success' => true, 'message' => __( 'Canonical cost source switched to WooCommerce COGS. MarginBeacon legacy costs were kept and are no longer used for calculations.', 'marginbeacon' ), ); } /** * Explicit switch back to MarginBeacon Cost (legacy data remains readable). * * @return array{success:bool,message:string} */ public function switch_back_to_marginbeacon() { $this->canonical->set( CanonicalCostSource::MARGINBEACON ); return array( 'success' => true, 'message' => __( 'Canonical cost source switched back to MarginBeacon Cost.', 'marginbeacon' ), ); } /** * Option 2: copy MB → Woo (non-overwrite), then switch. Batched for large catalogs. * * Resumes from a persisted keyset / subcursor / failed-id list when a prior job needs retry. * * @return array{success:bool,message:string,queued?:bool,result?:array} */ public function start_copy_and_switch() { if ( ! ( new CogsEnvironment() )->native_cogs_feature_enabled() ) { return array( 'success' => false, 'message' => __( 'WooCommerce COGS is not enabled. Enable it before migrating.', 'marginbeacon' ), ); } $empty = $this->empty_totals(); $state = $this->canonical->migration_state(); $prior = isset( $state['job'] ) && is_array( $state['job'] ) ? $state['job'] : array(); $resume = $this->job_is_resumable( $prior ); $state['job'] = array( 'status' => self::JOB_RUNNING, 'mode' => 'copy_and_switch', 'phase' => $resume && isset( $prior['phase'] ) ? (string) $prior['phase'] : self::PHASE_MAIN, 'cursor_after_id' => $resume ? (int) $prior['cursor_after_id'] : 0, 'active_parent_id' => $resume && isset( $prior['active_parent_id'] ) ? (int) $prior['active_parent_id'] : 0, 'variation_after_id' => $resume && isset( $prior['variation_after_id'] ) ? (int) $prior['variation_after_id'] : 0, 'batch_index' => $resume && isset( $prior['batch_index'] ) ? (int) $prior['batch_index'] : 0, 'totals' => $resume && isset( $prior['totals'] ) && is_array( $prior['totals'] ) ? $prior['totals'] : $empty, 'failed_ids' => $resume && isset( $prior['failed_ids'] ) && is_array( $prior['failed_ids'] ) ? array_map( 'intval', $prior['failed_ids'] ) : array(), 'reconcile_pass' => $resume && isset( $prior['reconcile_pass'] ) ? (int) $prior['reconcile_pass'] : 0, 'started' => $resume && ! empty( $prior['started'] ) ? (string) $prior['started'] : gmdate( 'Y-m-d H:i:s' ), 'resumed_at_gmt' => $resume ? gmdate( 'Y-m-d H:i:s' ) : null, 'continuation_error' => null, ); $state['last_mode'] = 'copy_and_switch'; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); $batch_index = (int) $state['job']['batch_index']; if ( $this->scheduler ) { $action_id = $this->scheduler->enqueue( Constants::ACTION_COGS_MIGRATION_BATCH, array( 'batch' => $batch_index ), Constants::SCHEDULER_GROUP, null, 'cogs_migration_' . $batch_index ); if ( SchedulerResult::is_enqueued( $action_id ) ) { return array( 'success' => true, 'queued' => true, 'message' => $resume ? __( 'COGS migration resumed. Progress continues in the background.', 'marginbeacon' ) : __( 'COGS migration queued. Progress continues in the background.', 'marginbeacon' ), ); } $state['job']['status'] = self::JOB_CONTINUATION_FAILED; $state['job']['continuation_error'] = 'scheduler_enqueue_failed'; $state['job']['failed_next_batch'] = $batch_index; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); return array( 'success' => false, 'queued' => false, 'result' => isset( $state['job']['totals'] ) ? $state['job']['totals'] : $empty, 'message' => __( 'COGS migration could not be queued (scheduler enqueue failed). Canonical source remains MarginBeacon Cost. Progress was saved — retry Copy and switch to resume. The catalog is never migrated synchronously in the admin request.', 'marginbeacon' ), ); } $state['job']['status'] = self::JOB_CONTINUATION_FAILED; $state['job']['continuation_error'] = 'scheduler_unavailable'; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); return array( 'success' => false, 'queued' => false, 'result' => isset( $state['job']['totals'] ) ? $state['job']['totals'] : $empty, 'message' => __( 'COGS migration requires a background scheduler. Canonical source remains MarginBeacon Cost. Retry when Action Scheduler is available — the catalog is never migrated synchronously in the admin request.', 'marginbeacon' ), ); } /** * Process one migration batch. Idempotent and retry-safe. * * @param int $batch Batch index (informational / dedupe). * @param bool $run_to_completion When true, loop batches synchronously. * @return array */ public function run_batch( $batch = 0, $run_to_completion = false ) { $state = $this->canonical->migration_state(); $job = isset( $state['job'] ) && is_array( $state['job'] ) ? $state['job'] : array(); $job = $this->normalize_job( $job ); $batch = (int) $batch; $budget = self::BATCH_SIZE; if ( self::PHASE_RECONCILE === $job['phase'] ) { $done_tick = $this->run_reconcile_tick( $job, $budget ); } else { $done_tick = $this->run_main_tick( $job, $budget ); } if ( ! empty( $done_tick['terminal'] ) ) { // Terminal helpers already persisted authoritative job state. return $done_tick['result']; } $job['batch_index'] = $batch; $job['continuation_error'] = null; $job['totals']['failed'] = count( $job['failed_ids'] ); $job['status'] = self::JOB_RUNNING; $state['job'] = $job; $state['last_result'] = $job['totals']; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); if ( $run_to_completion ) { return $this->run_batch( $batch + 1, true ); } return $this->enqueue_or_pause( $job, $batch ); } /** * Copy rules for a single product/variation ID. * * @param int $product_id Product ID. * @return string migrated|same|conflicts|skipped|failed|requires_review */ public function migrate_one( $product_id ) { $manual = $this->store->read_manual( (int) $product_id ); if ( null === $manual || ! $manual->is_resolved() || ! $manual->amount() ) { return 'skipped'; } // Explicit zero is resolved on MarginBeacon but Woo cannot store it (0 → Missing). if ( $manual->amount()->is_zero() ) { return 'requires_review'; } $decimal = $manual->amount()->to_decimal_string(); return $this->store->write_native( (int) $product_id, $decimal, true ); } /** * @param array $totals Result counters. * @return string */ public function format_result_message( array $totals ) { return sprintf( /* translators: 1: migrated 2: same 3: conflicts 4: skipped 5: failed 6: requires review */ __( 'Migration finished. Migrated: %1$d · Same: %2$d · Conflicts (skipped): %3$d · Skipped: %4$d · Failed: %5$d · Requires review: %6$d', 'marginbeacon' ), isset( $totals['migrated'] ) ? (int) $totals['migrated'] : 0, isset( $totals['same'] ) ? (int) $totals['same'] : 0, isset( $totals['conflicts'] ) ? (int) $totals['conflicts'] : 0, isset( $totals['skipped'] ) ? (int) $totals['skipped'] : 0, isset( $totals['failed'] ) ? (int) $totals['failed'] : 0, isset( $totals['requires_review'] ) ? (int) $totals['requires_review'] : 0 ); } /** * @return array{migrated:int,skipped:int,same:int,conflicts:int,failed:int,requires_review:int} */ private function empty_totals() { return array( 'migrated' => 0, 'skipped' => 0, 'same' => 0, 'conflicts' => 0, 'failed' => 0, 'requires_review' => 0, ); } /** * @param array $job Job state. * @return array */ private function normalize_job( array $job ) { if ( ! isset( $job['totals'] ) || ! is_array( $job['totals'] ) ) { $job['totals'] = $this->empty_totals(); } foreach ( $this->empty_totals() as $key => $zero ) { if ( ! isset( $job['totals'][ $key ] ) ) { $job['totals'][ $key ] = $zero; } } $job['phase'] = isset( $job['phase'] ) ? (string) $job['phase'] : self::PHASE_MAIN; $job['cursor_after_id'] = isset( $job['cursor_after_id'] ) ? (int) $job['cursor_after_id'] : 0; $job['active_parent_id'] = isset( $job['active_parent_id'] ) ? (int) $job['active_parent_id'] : 0; $job['variation_after_id'] = isset( $job['variation_after_id'] ) ? (int) $job['variation_after_id'] : 0; $job['reconcile_pass'] = isset( $job['reconcile_pass'] ) ? (int) $job['reconcile_pass'] : 0; $job['failed_ids'] = isset( $job['failed_ids'] ) && is_array( $job['failed_ids'] ) ? array_values( array_unique( array_map( 'intval', $job['failed_ids'] ) ) ) : array(); return $job; } /** * @param array $job Job state. * @return bool */ private function job_is_resumable( array $job ) { $status = isset( $job['status'] ) ? (string) $job['status'] : ''; if ( ! in_array( $status, array( self::JOB_CONTINUATION_FAILED, self::JOB_RUNNING, self::JOB_RETRY_REQUIRED ), true ) ) { return false; } return isset( $job['mode'] ) && 'copy_and_switch' === $job['mode'] && isset( $job['cursor_after_id'] ); } /** * Main catalog walk with parent keyset + variation subcursor (bounded TARGET_CHUNK work). * * @param array $job Job (by ref). * @param int $budget Remaining migrate slots. * @return array{terminal?:bool,result?:array} */ private function run_main_tick( array &$job, $budget ) { $budget = (int) $budget; while ( $budget > 0 ) { if ( $job['active_parent_id'] <= 0 ) { $parents = $this->fetch_parent_ids_after( (int) $job['cursor_after_id'], 1 ); if ( empty( $parents ) ) { $job['phase'] = self::PHASE_RECONCILE; $job['active_parent_id'] = 0; $job['variation_after_id'] = 0; return $this->run_reconcile_tick( $job, $budget ); } $job['active_parent_id'] = (int) $parents[0]; $job['variation_after_id'] = 0; } $parent_id = (int) $job['active_parent_id']; $targets = $this->expand_parent_targets( $parent_id ); $advanced = false; foreach ( $targets as $product_id ) { $product_id = (int) $product_id; if ( $product_id <= (int) $job['variation_after_id'] ) { continue; } $advanced = true; $outcome = $this->migrate_one( $product_id ); $this->apply_outcome( $job, $product_id, $outcome ); $job['variation_after_id'] = $product_id; --$budget; if ( $budget <= 0 ) { $has_more = false; foreach ( $targets as $tid ) { if ( (int) $tid > (int) $job['variation_after_id'] ) { $has_more = true; break; } } if ( ! $has_more ) { $job['cursor_after_id'] = $parent_id; $job['active_parent_id'] = 0; $job['variation_after_id'] = 0; } $job['status'] = self::JOB_RUNNING; return array(); } } // Parent complete (including parents with zero children). $job['cursor_after_id'] = $parent_id; $job['active_parent_id'] = 0; $job['variation_after_id'] = 0; if ( ! $advanced && empty( $targets ) ) { // Keep walking; empty expand still advances cursor. continue; } } $job['status'] = self::JOB_RUNNING; return array(); } /** * Retry failed IDs + scan for copyable MB→Woo gaps before any canonical switch. * * @param array $job Job (by ref). * @param int $budget Remaining migrate slots. * @return array{terminal?:bool,result?:array} */ private function run_reconcile_tick( array &$job, $budget ) { $budget = (int) $budget; $progress = false; // Deterministic retry of persisted write failures first. $failed_ids = $job['failed_ids']; foreach ( $failed_ids as $product_id ) { if ( $budget <= 0 ) { $job['status'] = self::JOB_RUNNING; return array(); } $outcome = $this->migrate_one( (int) $product_id ); $before = count( $job['failed_ids'] ); $this->apply_outcome( $job, (int) $product_id, $outcome ); if ( count( $job['failed_ids'] ) < $before || in_array( $outcome, array( 'migrated', 'same', 'conflicts', 'skipped', 'requires_review' ), true ) ) { if ( 'failed' !== $outcome ) { $progress = true; } } --$budget; } $gaps = $this->find_copyable_gap_ids( max( 1, $budget > 0 ? $budget : self::BATCH_SIZE ) ); if ( empty( $gaps ) && empty( $job['failed_ids'] ) ) { return array( 'terminal' => true, 'result' => $this->finish_copy_and_switch( $job ), ); } foreach ( $gaps as $product_id ) { if ( $budget <= 0 ) { $job['status'] = self::JOB_RUNNING; return array(); } $product_id = (int) $product_id; $outcome = $this->migrate_one( $product_id ); $this->apply_outcome( $job, $product_id, $outcome ); if ( 'failed' !== $outcome ) { $progress = true; } --$budget; } if ( empty( $job['failed_ids'] ) && empty( $this->find_copyable_gap_ids( 1 ) ) ) { return array( 'terminal' => true, 'result' => $this->finish_copy_and_switch( $job ), ); } ++$job['reconcile_pass']; if ( ! empty( $job['failed_ids'] ) && ! $progress ) { return array( 'terminal' => true, 'result' => $this->persist_retry_required( $job, 'write_failures', __( 'Migration could not copy one or more costs into WooCommerce COGS. Canonical source was not switched. Resume to retry the failed products.', 'marginbeacon' ) ), ); } if ( $job['reconcile_pass'] >= self::MAX_RECONCILE_PASSES ) { $still_gaps = $this->find_copyable_gap_ids( 1 ); if ( ! empty( $still_gaps ) || ! empty( $job['failed_ids'] ) ) { return array( 'terminal' => true, 'result' => $this->persist_retry_required( $job, 'reconcile_no_progress', __( 'Migration reconciliation could not finish cleanly (catalog may still be changing, or writes keep failing). Canonical source was not switched.', 'marginbeacon' ) ), ); } return array( 'terminal' => true, 'result' => $this->finish_copy_and_switch( $job ), ); } $job['status'] = self::JOB_RUNNING; return array(); } /** * @param array $job Job. * @param int $product_id Target. * @param string $outcome Outcome code. * @return void */ private function apply_outcome( array &$job, $product_id, $outcome ) { $product_id = (int) $product_id; $outcome = (string) $outcome; if ( 'failed' === $outcome ) { $this->remember_failure( $job, $product_id ); } else { $was_failed = $this->forget_failure( $job, $product_id ); if ( ! isset( $job['totals'][ $outcome ] ) ) { $job['totals']['failed'] = count( $job['failed_ids'] ); return; } $job['totals'][ $outcome ] = (int) $job['totals'][ $outcome ] + 1; unset( $was_failed ); } $job['totals']['failed'] = count( $job['failed_ids'] ); } /** * @param array $job Job. * @param int $product_id ID. * @return void */ private function remember_failure( array &$job, $product_id ) { $product_id = (int) $product_id; if ( in_array( $product_id, $job['failed_ids'], true ) ) { return; } if ( count( $job['failed_ids'] ) >= self::MAX_FAILED_IDS ) { return; } $job['failed_ids'][] = $product_id; } /** * @param array $job Job. * @param int $product_id ID. * @return bool True when removed. */ private function forget_failure( array &$job, $product_id ) { $product_id = (int) $product_id; $before = count( $job['failed_ids'] ); $job['failed_ids'] = array_values( array_filter( $job['failed_ids'], static function ( $id ) use ( $product_id ) { return (int) $id !== $product_id; } ) ); return count( $job['failed_ids'] ) < $before; } /** * @param array $job Job after reconcile clean. * @return array */ private function finish_copy_and_switch( array $job ) { $totals = isset( $job['totals'] ) && is_array( $job['totals'] ) ? $job['totals'] : $this->empty_totals(); $totals['failed'] = count( isset( $job['failed_ids'] ) ? $job['failed_ids'] : array() ); if ( $totals['failed'] > 0 || ! empty( $job['failed_ids'] ) ) { return $this->persist_retry_required( $job, 'write_failures', __( 'Migration copy reported write failures. Canonical source remains MarginBeacon Cost until every failure is resolved.', 'marginbeacon' ) ); } if ( ! empty( $totals['requires_review'] ) ) { return $this->persist_retry_required( $job, 'unsupported_zero', __( 'Some MarginBeacon costs are explicit zero. WooCommerce native COGS cannot preserve zero (it becomes Missing), so those products were not copied and the canonical source was not switched. Change those costs or use Switch without copying after reviewing the catalog.', 'marginbeacon' ) ); } $gaps = $this->find_copyable_gap_ids( 1 ); if ( ! empty( $gaps ) ) { return $this->persist_retry_required( $job, 'unresolved_gaps', __( 'Migration still found copyable MarginBeacon costs without WooCommerce COGS. Canonical source was not switched.', 'marginbeacon' ) ); } if ( ! ( new CogsEnvironment() )->native_cogs_feature_enabled() ) { return $this->persist_retry_required( $job, 'woo_cogs_unavailable', __( 'Migration copy finished, but WooCommerce COGS is no longer enabled. Canonical source was not switched.', 'marginbeacon' ) ); } $this->canonical->set( CanonicalCostSource::WOOCOMMERCE ); $state = $this->canonical->migration_state(); $job['status'] = self::JOB_COMPLETED; $job['completed_at'] = gmdate( 'Y-m-d H:i:s' ); $job['phase'] = self::PHASE_RECONCILE; $state['job'] = $job; $state['last_result'] = $totals; $state['last_mode'] = 'copy_and_switch'; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); return array( 'completed' => true, 'totals' => $totals, ); } /** * @param array $job Job. * @param string $code Error code. * @param string $message Merchant message. * @return array */ private function persist_retry_required( array $job, $code, $message ) { $state = $this->canonical->migration_state(); $job['status'] = self::JOB_RETRY_REQUIRED; $job['continuation_error'] = (string) $code; $job['totals']['failed'] = count( $job['failed_ids'] ); $state['job'] = $job; $state['last_result'] = $job['totals']; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); return array( 'completed' => false, 'totals' => $job['totals'], 'message' => (string) $message, 'retry_required' => true, ); } /** * @param array $job Job. * @param int $batch Batch index. * @return array */ private function enqueue_or_pause( array $job, $batch ) { $state = $this->canonical->migration_state(); $job['totals']['failed'] = count( $job['failed_ids'] ); $state['job'] = $job; $state['last_result'] = $job['totals']; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); if ( ! $this->scheduler ) { $job['status'] = self::JOB_CONTINUATION_FAILED; $job['continuation_error'] = 'scheduler_unavailable'; $state['job'] = $job; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); return array( 'completed' => false, 'totals' => $job['totals'], 'cursor_after_id' => (int) $job['cursor_after_id'], 'message' => __( 'Migration paused: background scheduler is unavailable. Retry the migration to continue.', 'marginbeacon' ), ); } $next_batch = (int) $batch + 1; $action_id = $this->scheduler->enqueue( Constants::ACTION_COGS_MIGRATION_BATCH, array( 'batch' => $next_batch ), Constants::SCHEDULER_GROUP, null, 'cogs_migration_' . $next_batch ); if ( ! SchedulerResult::is_enqueued( $action_id ) ) { $job['status'] = self::JOB_CONTINUATION_FAILED; $job['continuation_error'] = 'scheduler_enqueue_failed'; $job['failed_next_batch'] = $next_batch; $state['job'] = $job; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); return array( 'completed' => false, 'totals' => $job['totals'], 'cursor_after_id' => (int) $job['cursor_after_id'], 'continuation_enqueued' => false, 'continuation_error' => 'scheduler_enqueue_failed', 'message' => __( 'Migration paused: could not schedule the next batch. Canonical source was not switched. Retry the migration to continue.', 'marginbeacon' ), ); } $job['status'] = self::JOB_RUNNING; $state['job'] = $job; update_option( Constants::OPTION_COGS_MIGRATION_STATE, $state, false ); return array( 'completed' => false, 'totals' => $job['totals'], 'cursor_after_id' => (int) $job['cursor_after_id'], 'continuation_enqueued' => true, ); } /** * Targets that still have usable MB legacy cost and no usable Woo COGS. * * @param int $limit Max IDs. * @return int[] */ private function find_copyable_gap_ids( $limit ) { global $wpdb; $limit = max( 1, (int) $limit ); if ( isset( $wpdb ) && is_object( $wpdb ) && isset( $wpdb->posts, $wpdb->postmeta ) && is_string( $wpdb->posts ) && is_string( $wpdb->postmeta ) && method_exists( $wpdb, 'get_col' ) && method_exists( $wpdb, 'prepare' ) ) { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND pm.meta_value IS NOT NULL AND pm.meta_value <> '' AND p.post_type IN ('product','product_variation') AND p.post_status IN ('publish','private') AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} woo WHERE woo.post_id = pm.post_id AND woo.meta_key = '_cogs_total_value' AND woo.meta_value IS NOT NULL AND woo.meta_value <> '' ) ORDER BY pm.post_id ASC LIMIT %d", Constants::META_MANUAL_COST, $limit ) ); if ( is_array( $ids ) && ! empty( $ids ) ) { $out = array(); foreach ( array_map( 'intval', $ids ) as $id ) { if ( $this->is_copyable_gap( $id ) ) { $out[] = $id; } if ( count( $out ) >= $limit ) { break; } } if ( ! empty( $out ) ) { return $out; } } } $out = array(); $parent_ids = $this->fetch_parent_ids_after( 0, PHP_INT_MAX ); foreach ( $parent_ids as $parent_id ) { foreach ( $this->expand_parent_targets( (int) $parent_id ) as $product_id ) { if ( $this->is_copyable_gap( (int) $product_id ) ) { $out[] = (int) $product_id; if ( count( $out ) >= $limit ) { return $out; } } } } return $out; } /** * @param int $product_id Target ID. * @return bool */ private function is_copyable_gap( $product_id ) { $product_id = (int) $product_id; $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null; if ( $product && 'variable' === $product->get_type() ) { return false; } $manual = $this->store->read_manual( $product_id ); if ( null === $manual || ! $manual->is_resolved() || ! $manual->amount() ) { return false; } // Explicit zero is not copyable: Woo collapses 0 → Missing. Counted as requires_review, not a gap. if ( $manual->amount()->is_zero() ) { return false; } $native = $this->store->read_native( $product ); // Authoritative presence: only Exact resolved native closes the gap (not meta-only / ambiguous). if ( null !== $native && $native->is_resolved() ) { return false; } return true; } /** * Reporting-only target walk from an already-fetched parent page (may apply TARGET_CAP). * * @param int[] $parent_ids Parent product IDs. * @return \Generator */ private function iterate_summary_targets_from_parents( array $parent_ids ) { $emitted = 0; foreach ( $parent_ids as $product_id ) { if ( $emitted >= CostCoverageService::TARGET_CAP ) { return; } foreach ( $this->expand_parent_targets( (int) $product_id ) as $target_id ) { if ( $emitted >= CostCoverageService::TARGET_CAP ) { return; } ++$emitted; yield array( 'product_id' => (int) $target_id ); } } } /** * Keyset page of published parent product IDs. * * @param int $after_id Exclusive lower bound. * @param int $limit Page size. * @return int[] */ private function fetch_parent_ids_after( $after_id, $limit ) { global $wpdb; $after_id = (int) $after_id; $limit = max( 1, (int) $limit ); if ( isset( $wpdb ) && is_object( $wpdb ) && isset( $wpdb->posts ) && is_string( $wpdb->posts ) && method_exists( $wpdb, 'get_col' ) && method_exists( $wpdb, 'prepare' ) && PHP_INT_MAX !== $limit ) { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status = 'publish' AND ID > %d ORDER BY ID ASC LIMIT %d", $after_id, $limit ) ); if ( ! is_array( $ids ) ) { return array(); } return array_map( 'intval', $ids ); } $parent_ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); if ( ! is_array( $parent_ids ) ) { return array(); } $out = array(); foreach ( $parent_ids as $id ) { $id = (int) $id; if ( $id <= $after_id ) { continue; } $out[] = $id; if ( count( $out ) >= $limit ) { break; } } return $out; } /** * Expand a parent product into migration target IDs (variations or the parent itself). * * @param int $parent_id Parent product ID. * @return int[] */ private function expand_parent_targets( $parent_id ) { $parent_id = (int) $parent_id; $product = function_exists( 'wc_get_product' ) ? wc_get_product( $parent_id ) : null; if ( ! $product ) { return array(); } if ( 'variable' === $product->get_type() ) { $children = $product->get_children(); if ( ! is_array( $children ) ) { return array(); } $ids = array_map( 'intval', $children ); sort( $ids, SORT_NUMERIC ); return $ids; } return array( $parent_id ); } }