order_risk = $order_risk ? $order_risk : new OrderRiskRepository(); $this->acknowledgements = $acknowledgements ? $acknowledgements : new AcknowledgementService(); $this->scheduler = $scheduler instanceof SchedulerInterface ? $scheduler : null; } /** * Sync one order's current snapshot. * * @param int $order_id Order ID. * @return bool True when snapshot patched (or nothing to do). */ public function sync_order( $order_id ) { $order_id = (int) $order_id; if ( $order_id <= 0 ) { return false; } $current = $this->order_risk->get_current( $order_id ); if ( ! $current || 'completed' !== (string) $current['processing_state'] ) { return false; } return $this->sync_snapshot( $current ); } /** * Sync all completed current snapshots that include a catalog entity on a line (or coupon on order). * * Coupon scope is batched via Action Scheduler when available (partial progress metadata). * * @param string $scope_type product|variation|coupon. * @param int $entity_id Entity ID. * @return void */ public function sync_for_catalog_entity( $scope_type, $entity_id ) { $scope_type = sanitize_key( (string) $scope_type ); $entity_id = (int) $entity_id; if ( $entity_id <= 0 ) { return; } if ( AcknowledgementService::SCOPE_COUPON === $scope_type ) { $result = $this->start_coupon_sync( $entity_id ); if ( ! empty( $result['error'] ) ) { ( new \Kayvandra\MarginBeacon\Infrastructure\Persistence\OperationalEventsRepository() )->record( 'acknowledgement', 'coupon:' . $entity_id, null, 'ack_sync_enqueue_failed', array( 'coupon_id' => $entity_id, 'error' => (string) $result['error'], 'queued' => ! empty( $result['queued'] ), 'partial' => ! empty( $result['partial'] ), ), 'ack_sync_enqueue_failed' ); } return; } foreach ( $this->order_risk->find_order_ids_for_catalog_entity( $scope_type, $entity_id ) as $order_id ) { $this->sync_order( (int) $order_id ); } } /** * Begin coupon acknowledgement sync (scheduler preferred). * * @param int $coupon_id Coupon ID. * @return array{queued:bool,partial:bool,synced:int,error?:string} */ public function start_coupon_sync( $coupon_id ) { $coupon_id = (int) $coupon_id; $scheduler = $this->scheduler instanceof SchedulerInterface ? $this->scheduler : ( Plugin::instance() ? Plugin::instance()->scheduler() : null ); if ( $scheduler instanceof SchedulerInterface ) { $action_id = $scheduler->enqueue( Constants::ACTION_SYNC_ACK_COUPON, array( 'coupon_id' => $coupon_id, 'before_id' => null, ), Constants::SCHEDULER_GROUP, null, 'ack_coupon_' . $coupon_id . '_start' ); if ( ! SchedulerResult::is_enqueued( $action_id ) ) { $this->store_coupon_sync_progress( $coupon_id, array( 'status' => 'enqueue_failed', 'before_id' => null, 'synced' => 0, 'partial' => true, 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); return array( 'queued' => false, 'partial' => true, 'synced' => 0, 'error' => __( 'Failed to enqueue coupon acknowledgement sync.', 'marginbeacon' ), ); } $this->store_coupon_sync_progress( $coupon_id, array( 'status' => 'queued', 'before_id' => null, 'synced' => 0, 'partial' => true, 'started_at_gmt' => gmdate( 'Y-m-d H:i:s' ), 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); return array( 'queued' => true, 'partial' => true, 'synced' => 0, ); } // Fail-soft without scheduler: sync one keyset page and mark partial. $page = $this->order_risk->find_order_ids_for_coupon_page( $coupon_id, self::COUPON_SYNC_PAGE, null ); $synced = 0; foreach ( $page['order_ids'] as $order_id ) { if ( $this->sync_order( (int) $order_id ) ) { ++$synced; } } $this->store_coupon_sync_progress( $coupon_id, array( 'status' => 'completed_inline', 'before_id' => (int) $page['next_before_id'], 'synced' => $synced, 'partial' => ! empty( $page['has_more'] ), 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); return array( 'queued' => false, 'partial' => ! empty( $page['has_more'] ), 'synced' => $synced, ); } /** * Process one coupon ack sync batch (AS callback). Keyset: id < before_id. * * @param int $coupon_id Coupon ID. * @param int|null $before_id Exclusive upper bound on risk.id (null = first page). * @return array{synced:int,has_more:bool,next_before_id:int} * @throws \RuntimeException When continuation enqueue fails mid-run. */ public function process_coupon_sync_batch( $coupon_id, $before_id = null ) { $coupon_id = (int) $coupon_id; $before = null === $before_id || '' === $before_id ? null : (int) $before_id; $batch_id = null === $before ? 'start' : (string) $before; $prev = $this->coupon_sync_progress( $coupon_id ); $completed = isset( $prev['completed_batches'] ) && is_array( $prev['completed_batches'] ) ? $prev['completed_batches'] : array(); if ( isset( $completed[ $batch_id ] ) ) { // Idempotent AS retry: do not double-count; resume from stored cursor. $total = isset( $prev['synced'] ) ? (int) $prev['synced'] : 0; $next = isset( $prev['before_id'] ) ? (int) $prev['before_id'] : 0; $has_more = ! empty( $prev['partial'] ); } else { $page = $this->order_risk->find_order_ids_for_coupon_page( $coupon_id, self::COUPON_SYNC_PAGE, $before ); $synced = 0; foreach ( $page['order_ids'] as $order_id ) { if ( $this->sync_order( (int) $order_id ) ) { ++$synced; } } $total = ( isset( $prev['synced'] ) ? (int) $prev['synced'] : 0 ) + $synced; $next = (int) $page['next_before_id']; $has_more = ! empty( $page['has_more'] ); $completed[ $batch_id ] = true; $this->require_coupon_sync_progress( $coupon_id, array( 'status' => $has_more ? 'running' : 'completed', 'before_id' => $next, 'synced' => $total, 'partial' => $has_more, 'completed_batches' => $completed, 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); } if ( $has_more ) { $scheduler = Plugin::instance()->scheduler(); if ( ! $scheduler instanceof SchedulerInterface ) { $this->require_coupon_sync_progress( $coupon_id, array( 'status' => 'enqueue_failed', 'before_id' => $next, 'synced' => $total, 'partial' => true, 'completed_batches' => $completed, 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); $this->record_coupon_sync_failure( $coupon_id, 'ack_sync_continuation_enqueue_failed', array( 'error' => 'scheduler_unavailable', 'before_id' => $next, ) ); throw new \RuntimeException( 'MarginBeacon coupon ack sync continuation: scheduler unavailable' ); } $action_id = $scheduler->enqueue( Constants::ACTION_SYNC_ACK_COUPON, array( 'coupon_id' => $coupon_id, 'before_id' => $next, ), Constants::SCHEDULER_GROUP, null, 'ack_coupon_' . $coupon_id . '_' . $next ); if ( ! SchedulerResult::is_enqueued( $action_id ) ) { $this->require_coupon_sync_progress( $coupon_id, array( 'status' => 'enqueue_failed', 'before_id' => $next, 'synced' => $total, 'partial' => true, 'completed_batches' => $completed, 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); $this->record_coupon_sync_failure( $coupon_id, 'ack_sync_continuation_enqueue_failed', array( 'error' => 'enqueue_failed', 'before_id' => $next, ) ); throw new \RuntimeException( 'MarginBeacon coupon ack sync continuation enqueue failed' ); } } return array( 'synced' => $total, 'has_more' => $has_more, 'next_before_id' => $next, ); } /** * @param int $coupon_id Coupon ID. * @return array */ public function coupon_sync_progress( $coupon_id ) { $raw = get_option( Constants::OPTION_ACK_SYNC_PREFIX . (int) $coupon_id, array() ); return is_array( $raw ) ? $raw : array(); } /** * @param int $coupon_id Coupon ID. * @param array $data Progress. * @return bool */ private function store_coupon_sync_progress( $coupon_id, array $data ) { $key = Constants::OPTION_ACK_SYNC_PREFIX . (int) $coupon_id; $updated = update_option( $key, $data, false ); return self::option_persist_ok( $updated, get_option( $key, null ), $data ); } /** * WordPress update_option returns false when the value is unchanged — treat as success. * * @param bool|mixed $updated update_option result. * @param mixed $stored Value after write. * @param mixed $intended Intended value. * @return bool */ public static function option_persist_ok( $updated, $stored, $intended ) { if ( false !== $updated ) { return true; } return $stored == $intended; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison,Universal.Operators.StrictComparisons.LooseEqual -- option round-trip. } /** * Fail-closed progress write for AS batch (retry when option write fails). * * @param int $coupon_id Coupon ID. * @param array $data Progress. * @return void * @throws \RuntimeException When option write fails. */ private function require_coupon_sync_progress( $coupon_id, array $data ) { if ( ! $this->store_coupon_sync_progress( $coupon_id, $data ) ) { throw new \RuntimeException( 'MarginBeacon coupon ack sync progress persist failed' ); } } /** * @param int $coupon_id Coupon ID. * @param string $reason Reason code. * @param array $context Context. * @return void */ private function record_coupon_sync_failure( $coupon_id, $reason, array $context = array() ) { ( new \Kayvandra\MarginBeacon\Infrastructure\Persistence\OperationalEventsRepository() )->record( 'acknowledgement', 'coupon:' . (int) $coupon_id, null, (string) $reason, array_merge( array( 'coupon_id' => (int) $coupon_id ), $context ), (string) $reason ); } /** * @param array $row Current order-risk row. * @return bool */ private function sync_snapshot( array $row ) { $order_id = (int) $row['order_id']; $lines = $this->order_risk->get_lines_for_order( $order_id ); $coupon_ids = $this->coupon_ids_for_order( $order_id ); $resolved = $this->resolve_ack_from_lines( $lines, isset( $row['risk_badge'] ) ? (string) $row['risk_badge'] : '', isset( $row['short_reason_code'] ) ? (string) $row['short_reason_code'] : '', $coupon_ids ); return $this->order_risk->update_acknowledgement_ref( (int) $row['id'], $resolved['ref'], $resolved['count'], $row ); } /** * Coupon post IDs applied on a Woo order (empty when WC unavailable). * * @param int $order_id Order ID. * @return int[] */ public function coupon_ids_for_order( $order_id ) { $order_id = (int) $order_id; if ( $order_id <= 0 || ! function_exists( 'wc_get_order' ) || ! class_exists( '\WC_Coupon' ) ) { return array(); } $order = wc_get_order( $order_id ); if ( ! $order ) { return array(); } $ids = array(); foreach ( $order->get_coupon_codes() as $code ) { $coupon = new \WC_Coupon( (string) $code ); if ( $coupon->get_id() ) { $ids[] = (int) $coupon->get_id(); } } return array_values( array_unique( $ids ) ); } /** * Mirror OrderRiskEvaluatorRunner acknowledgement linking using persisted line rows (+ coupons). * * @param array> $lines Line rows. * @param string $order_badge Order badge. * @param string $short_reason Order short reason. * @param int[] $coupon_ids Applied coupon IDs on the order. * @return array{ref:?int,count:int} */ public function resolve_ack_from_lines( array $lines, $order_badge, $short_reason, array $coupon_ids = array() ) { $risk_sevs = array( Severity::CRITICAL, Severity::HIGH, Severity::WARNING ); $ack_ref = null; $ack_count = 0; foreach ( $lines as $line ) { $line_sev = isset( $line['risk_badge'] ) ? (string) $line['risk_badge'] : ''; $line_reason = isset( $line['reason_code'] ) ? (string) $line['reason_code'] : ''; if ( ! in_array( $line_sev, $risk_sevs, true ) && 'MB014' !== $line_reason ) { continue; } $ack_row = $this->acknowledgements->active_for_catalog( (int) $line['product_id'], (int) $line['variation_id'] ); if ( $ack_row ) { ++$ack_count; $ack_ref = (int) $ack_row['id']; } } if ( ! $ack_ref && in_array( $order_badge, $risk_sevs, true ) ) { foreach ( $lines as $line ) { $found = $this->acknowledgements->active_for_catalog( (int) $line['product_id'], (int) $line['variation_id'] ); if ( $found ) { ++$ack_count; $ack_ref = (int) $found['id']; break; } } } if ( ! $ack_ref && method_exists( $this->acknowledgements, 'active_for_coupon' ) ) { $order_needs_ack = in_array( $order_badge, $risk_sevs, true ) || 'MB014' === (string) $short_reason; if ( $order_needs_ack ) { foreach ( $coupon_ids as $coupon_id ) { $coupon_ack = $this->acknowledgements->active_for_coupon( (int) $coupon_id ); if ( $coupon_ack ) { ++$ack_count; $ack_ref = (int) $coupon_ack['id']; break; } } } } return array( 'ref' => $ack_ref, 'count' => $ack_count, ); } }