repo = new AcknowledgementsRepository(); $this->events = new OperationalEventsRepository(); } /** * Whether current user may acknowledge. * * @return bool */ public static function current_user_can_acknowledge() { return current_user_can( Constants::CAP_ACKNOWLEDGE ) || Capabilities::current_user_can_manage(); } /** * Build catalog revision hash for a product/variation (material fields). * * @param int $product_id Product ID. * @param int $variation_id Variation ID. * @return string */ public function catalog_revision( $product_id, $variation_id = 0 ) { $product = null; if ( $variation_id && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $variation_id ); } if ( ! $product && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $product_id ); } $payload = array( 'product_id' => (int) $product_id, 'variation_id' => (int) $variation_id, 'formula' => AKSMB_FORMULA_VERSION, ); if ( $product ) { $payload['regular'] = (string) $product->get_regular_price(); $payload['sale'] = (string) $product->get_sale_price(); $payload['price'] = (string) $product->get_price(); $target_id = $variation_id ? $variation_id : $product_id; $payload['manual'] = (string) get_post_meta( $target_id, Constants::META_MANUAL_COST, true ); $payload['categories'] = $product->get_category_ids(); $cost_settings = get_option( Constants::OPTION_COST_SETTINGS, array() ); $meta_key = is_array( $cost_settings ) && ! empty( $cost_settings['custom_meta_key'] ) ? (string) $cost_settings['custom_meta_key'] : ''; $payload['custom_meta'] = $meta_key ? (string) get_post_meta( $target_id, $meta_key, true ) : ''; if ( is_callable( array( $product, 'get_cogs_total_value' ) ) ) { $payload['native_cogs'] = (string) $product->get_cogs_total_value(); } elseif ( is_callable( array( $product, 'get_cogs_value' ) ) ) { $payload['native_cogs'] = (string) $product->get_cogs_value(); } else { $payload['native_cogs'] = ''; } $resolved = ( new CostResolver() )->resolve_catalog( (int) $product_id, (int) $variation_id ); $payload['resolved_source'] = $resolved->source(); $payload['resolved_confidence'] = $resolved->confidence(); $payload['resolved_amount'] = $resolved->amount() ? $resolved->amount()->to_decimal_string() : ''; } $cost_settings = get_option( Constants::OPTION_COST_SETTINGS, array() ); $payload['cost_settings_hash'] = is_array( $cost_settings ) ? hash( 'sha256', (string) wp_json_encode( $cost_settings ) ) : ''; $cat_assumed = get_option( Constants::OPTION_CATEGORY_ASSUMED_COSTS, array() ); $payload['category_assumed_costs_hash'] = is_array( $cat_assumed ) ? hash( 'sha256', (string) wp_json_encode( $cat_assumed ) ) : ''; $payload['thresholds'] = ( new ThresholdResolver() )->resolve_global()->to_array(); $payload['category_thresholds_hash'] = hash( 'sha256', (string) apply_filters( 'aksmb_category_threshold_overrides_json', '[]' ) ); if ( $product ) { $cats = array_map( 'intval', (array) $product->get_category_ids() ); $payload['threshold_candidates'] = ( new ThresholdResolver() )->candidates_for_categories( $cats ); } return hash( 'sha256', (string) wp_json_encode( $payload ) ); } /** * Scope ref string. * * @param string $scope_type Scope type. * @param int $id Entity ID. * @return string */ public function scope_ref( $scope_type, $id ) { return $scope_type . ':' . (int) $id; } /** * Acknowledge a product or variation as intentional loss leader. * * @param string $scope_type product|variation|coupon. * @param int $entity_id Entity ID. * @param string $reason Optional reason. * @return array{success:bool,id?:int,error?:string} */ public function acknowledge( $scope_type, $entity_id, $reason = '' ) { if ( ! self::current_user_can_acknowledge() ) { return array( 'success' => false, 'error' => __( 'You do not have permission to acknowledge loss leaders.', 'marginbeacon' ), ); } $allowed = array( self::SCOPE_PRODUCT, self::SCOPE_VARIATION, self::SCOPE_COUPON ); if ( ! in_array( $scope_type, $allowed, true ) ) { return array( 'success' => false, 'error' => __( 'Invalid acknowledgement scope.', 'marginbeacon' ), ); } $entity_id = (int) $entity_id; if ( $entity_id <= 0 ) { return array( 'success' => false, 'error' => __( 'Invalid entity.', 'marginbeacon' ), ); } $revision = ''; if ( self::SCOPE_VARIATION === $scope_type ) { $parent = 0; $product = function_exists( 'wc_get_product' ) ? wc_get_product( $entity_id ) : null; if ( $product ) { $parent = (int) $product->get_parent_id(); } $revision = $this->catalog_revision( $parent ? $parent : $entity_id, $entity_id ); } elseif ( self::SCOPE_PRODUCT === $scope_type ) { $revision = $this->catalog_revision( $entity_id, 0 ); } else { $revision = 'coupon:' . $entity_id . ':' . AKSMB_FORMULA_VERSION; if ( function_exists( 'wc_get_coupon_id_by_code' ) || class_exists( '\WC_Coupon' ) ) { $coupon = new \WC_Coupon( (int) $entity_id ); if ( $coupon->get_id() ) { $revision = CouponMaterialHasher::hash_coupon( $coupon ); } } } $ref = $this->scope_ref( $scope_type, $entity_id ); $existing = $this->repo->find_active( $scope_type, $ref, $revision ); if ( $existing ) { ( new OrderRiskAcknowledgementSync() )->sync_for_catalog_entity( $scope_type, $entity_id ); return array( 'success' => true, 'id' => (int) $existing['id'], ); } $inserted = $this->repo->supersede_and_insert( $scope_type, $ref, array( 'revision_hash' => $revision, 'actor_user_id' => get_current_user_id(), 'reason' => $this->sanitize_free_text( $reason ), 'created_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); if ( empty( $inserted['success'] ) || empty( $inserted['id'] ) ) { $code = isset( $inserted['error'] ) ? (string) $inserted['error'] : 'insert_failed'; $msg = __( 'Failed to persist acknowledgement.', 'marginbeacon' ); if ( 'lock_timeout' === $code ) { $msg = __( 'Could not acquire acknowledgement lock. Try again.', 'marginbeacon' ); } elseif ( 'supersede_failed' === $code ) { $msg = __( 'Failed to supersede previous acknowledgement.', 'marginbeacon' ); } return array( 'success' => false, 'error' => $msg, ); } $id = (int) $inserted['id']; $this->events->record( 'acknowledgement', (string) $id, 'active', 'active', array( 'scope_type' => $scope_type, 'scope_ref' => $ref, 'revision_hash' => $revision, 'reason' => $this->sanitize_free_text( $reason ), 'actor' => get_current_user_id(), 'action' => 'acknowledge', ) ); ( new OrderRiskAcknowledgementSync() )->sync_for_catalog_entity( $scope_type, $entity_id ); return array( 'success' => true, 'id' => $id, ); } /** * Revoke an acknowledgement. * * @param int $id Acknowledgement ID. * @param string $reason Reason. * @return array{success:bool,error?:string} */ public function revoke( $id, $reason = '' ) { if ( ! self::current_user_can_acknowledge() ) { return array( 'success' => false, 'error' => __( 'You do not have permission to revoke acknowledgements.', 'marginbeacon' ), ); } $row = $this->repo->get( (int) $id ); if ( ! $row || 'active' !== $row['status'] ) { return array( 'success' => false, 'error' => __( 'Acknowledgement not found.', 'marginbeacon' ), ); } $updated = $this->repo->update( (int) $id, array( 'status' => 'revoked', 'revoke_reason' => $this->sanitize_free_text( $reason ), 'revoked_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); if ( ! $updated ) { return array( 'success' => false, 'error' => __( 'Failed to revoke acknowledgement.', 'marginbeacon' ), ); } ( new \Kayvandra\MarginBeacon\Infrastructure\Persistence\OrderRiskRepository() ) ->clear_acknowledgement_refs_for_ack( (int) $id ); $this->events->record( 'acknowledgement', (string) $id, 'active', 'revoked', array( 'scope_type' => $row['scope_type'], 'scope_ref' => $row['scope_ref'], 'reason' => $this->sanitize_free_text( $reason ), 'actor' => get_current_user_id(), 'action' => 'revoke', ) ); $entity_id = $this->entity_id_from_scope_ref( (string) $row['scope_ref'] ); if ( $entity_id > 0 ) { ( new OrderRiskAcknowledgementSync() )->sync_for_catalog_entity( (string) $row['scope_type'], $entity_id ); } return array( 'success' => true ); } /** * Trim and strip PII-like patterns from free-text acknowledgement reasons. * * @param mixed $reason Raw reason. * @return string */ private function sanitize_free_text( $reason ) { if ( ! is_string( $reason ) ) { return ''; } $text = trim( wp_strip_all_tags( $reason ) ); if ( '' === $text ) { return ''; } $text = preg_replace( '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', '[redacted]', $text ); $text = preg_replace( '/\+?\d[\d\s().-]{7,}\d/', '[redacted]', $text ); if ( strlen( $text ) > 500 ) { $text = substr( $text, 0, 497 ) . '...'; } return $text; } /** * Parse numeric entity ID from scope_ref (e.g. product:15). * * @param string $scope_ref Scope ref. * @return int */ private function entity_id_from_scope_ref( $scope_ref ) { $parts = explode( ':', (string) $scope_ref, 2 ); return isset( $parts[1] ) ? (int) $parts[1] : 0; } /** * Whether product/variation is currently acknowledged for its catalog revision. * * @param int $product_id Product ID. * @param int $variation_id Variation ID. * @return array|null Active row or null. */ public function active_for_catalog( $product_id, $variation_id = 0 ) { if ( $variation_id > 0 ) { $rev = $this->catalog_revision( $product_id, $variation_id ); $row = $this->repo->find_active( self::SCOPE_VARIATION, $this->scope_ref( self::SCOPE_VARIATION, $variation_id ), $rev ); if ( $row ) { return $row; } } $rev = $this->catalog_revision( $product_id, 0 ); return $this->repo->find_active( self::SCOPE_PRODUCT, $this->scope_ref( self::SCOPE_PRODUCT, $product_id ), $rev ); } /** * Whether a coupon is currently acknowledged for its material revision. * * @param int $coupon_id Coupon post ID. * @return array|null Active row or null. */ public function active_for_coupon( $coupon_id ) { $coupon_id = (int) $coupon_id; if ( $coupon_id <= 0 ) { return null; } $revision = 'coupon:' . $coupon_id . ':' . AKSMB_FORMULA_VERSION; if ( class_exists( '\WC_Coupon' ) ) { $coupon = new \WC_Coupon( $coupon_id ); if ( $coupon->get_id() ) { $revision = CouponMaterialHasher::hash_coupon( $coupon ); } } return $this->repo->find_active( self::SCOPE_COUPON, $this->scope_ref( self::SCOPE_COUPON, $coupon_id ), $revision ); } /** * Count acknowledgements that are still valid for the current material revision. * * Prefer count_active_fast() on admin hot paths; this method reconciles revisions. * * @return int */ public function count_active() { return (int) $this->count_active_report()['count']; } /** * Fast SQL count of status=active (no revision reconcile โ€” safe for Overview). * * @return int */ public function count_active_fast() { return $this->repo->count_active(); } /** * Count with partial metadata when reconciliation hits the scan ceiling. * * @return array{count:int,partial:bool,scanned:int,display:string} */ public function count_active_report() { $result = $this->reconcile_active( 0 ); $count = (int) $result['count']; $partial = ! empty( $result['partial'] ); return array( 'count' => $count, 'partial' => $partial, 'scanned' => (int) $result['scanned'], 'display' => $partial ? (string) $count . '+' : (string) $count, ); } /** * List active acknowledgements whose revision_hash still matches current material. * * @param int $limit Limit of valid rows to return. * @return array */ public function list_active( $limit = 50 ) { $result = $this->reconcile_active( max( 1, min( 500, (int) $limit ) ) ); return $result['rows']; } /** * Reconcile active acknowledgements with keyset scan. * * @param int $limit Max valid rows (0 = count all until max_scan). * @return array{rows:array,count:int,partial:bool,scanned:int,revoked:int} */ public function reconcile_active( $limit = 50 ) { $want_all = ( 0 === (int) $limit ); $want = $want_all ? PHP_INT_MAX : max( 1, (int) $limit ); $page = 100; $max_scan = 5000; $order_risk = new \Kayvandra\MarginBeacon\Infrastructure\Persistence\OrderRiskRepository(); return self::reconcile_keyset_loop( $want, $page, $max_scan, function ( $after_id, $page_size ) { return $this->repo->list_active_after_id( $page_size, $after_id ); }, function ( array $row ) { return $this->row_matches_current_revision( $row ); }, function ( array $row ) use ( $order_risk ) { $ok = $this->repo->update( (int) $row['id'], array( 'status' => 'revoked', 'revoke_reason' => 'stale_revision', 'revoked_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); if ( $ok ) { // Hot-path safe: clear refs only. Full catalog sync is for explicit ack/revoke actions. $order_risk->clear_acknowledgement_refs_for_ack( (int) $row['id'] ); } return $ok; } ); } /** * Shared keyset reconcile loop (production + tests). * * @param int $want Desired valid rows (PHP_INT_MAX = all). * @param int $page_size Page size. * @param int $max_scan Safety ceiling on rows examined. * @param callable $fetch function(int $after_id, int $page_size): array rows. * @param callable $is_valid function(array $row): bool. * @param callable $revoke function(array $row): bool. * @return array{rows:array,count:int,partial:bool,scanned:int,revoked:int} */ public static function reconcile_keyset_loop( $want, $page_size, $max_scan, callable $fetch, callable $is_valid, callable $revoke ) { $want = max( 1, (int) $want ); $page = max( 1, (int) $page_size ); $max_scan = max( 1, (int) $max_scan ); $valid = array(); $after_id = 0; $scanned = 0; $revoked = 0; $partial = false; while ( count( $valid ) < $want && $scanned < $max_scan ) { $rows = $fetch( $after_id, $page ); if ( ! is_array( $rows ) || empty( $rows ) ) { break; } foreach ( $rows as $row ) { $after_id = (int) $row['id']; ++$scanned; if ( $is_valid( $row ) ) { $valid[] = $row; if ( count( $valid ) >= $want ) { $peek = $fetch( $after_id, 1 ); $partial = is_array( $peek ) && ! empty( $peek ); break 2; } continue; } if ( $revoke( $row ) ) { ++$revoked; } } if ( count( $rows ) < $page ) { break; } } if ( $scanned >= $max_scan ) { $partial = true; } return array( 'rows' => $valid, 'count' => count( $valid ), 'partial' => $partial, 'scanned' => $scanned, 'revoked' => $revoked, ); } /** * Pure in-memory keyset map helper โ€” delegates to reconcile_keyset_loop. * * @param array $active_by_id Active rows keyed by id. * @param int $want Desired valid count. * @param int $page Page size. * @return array{valid_ids:int[],revoked:int[],partial:bool} */ public static function reconcile_keyset_map( array &$active_by_id, $want, $page = 2 ) { ksort( $active_by_id, SORT_NUMERIC ); $revoked_ids = array(); $result = self::reconcile_keyset_loop( $want, $page, 5000, static function ( $after_id, $page_size ) use ( &$active_by_id ) { $out = array(); foreach ( $active_by_id as $id => $row ) { if ( (int) $id <= (int) $after_id ) { continue; } $out[] = array( 'id' => (int) $id, 'valid' => ! empty( $row['valid'] ), ); if ( count( $out ) >= $page_size ) { break; } } return $out; }, static function ( array $row ) { return ! empty( $row['valid'] ); }, static function ( array $row ) use ( &$active_by_id, &$revoked_ids ) { unset( $active_by_id[ (int) $row['id'] ] ); $revoked_ids[] = (int) $row['id']; return true; } ); $ids = array(); foreach ( $result['rows'] as $row ) { $ids[] = (int) $row['id']; } return array( 'valid_ids' => $ids, 'revoked' => $revoked_ids, 'partial' => ! empty( $result['partial'] ), ); } /** * @param array $row Acknowledgement row. * @return bool */ private function row_matches_current_revision( array $row ) { $scope = isset( $row['scope_type'] ) ? (string) $row['scope_type'] : ''; $ref = isset( $row['scope_ref'] ) ? (string) $row['scope_ref'] : ''; $hash = isset( $row['revision_hash'] ) ? (string) $row['revision_hash'] : ''; if ( '' === $scope || '' === $ref || '' === $hash ) { return false; } $entity_id = (int) preg_replace( '/\D+/', '', $ref ); if ( self::SCOPE_PRODUCT === $scope ) { return hash_equals( $hash, $this->catalog_revision( $entity_id, 0 ) ); } if ( self::SCOPE_VARIATION === $scope ) { $parent = 0; $product = function_exists( 'wc_get_product' ) ? wc_get_product( $entity_id ) : null; if ( $product ) { $parent = (int) $product->get_parent_id(); } return hash_equals( $hash, $this->catalog_revision( $parent ? $parent : $entity_id, $entity_id ) ); } if ( self::SCOPE_COUPON === $scope && class_exists( '\WC_Coupon' ) ) { $coupon = new \WC_Coupon( $entity_id ); if ( ! $coupon->get_id() ) { return false; } return hash_equals( $hash, CouponMaterialHasher::hash_coupon( $coupon ) ); } return false; } }