repo = $repo ? $repo : new FindingsRepository(); } /** * Current calculation contract fingerprint (formula + Woo + adapter). * * @return string */ public function current_contract_fingerprint() { $woo = defined( 'WC_VERSION' ) ? (string) WC_VERSION : ''; $payload = array( 'formula' => defined( 'AKSMB_FORMULA_VERSION' ) ? AKSMB_FORMULA_VERSION : '', 'plugin' => defined( 'AKSMB_VERSION' ) ? AKSMB_VERSION : '', 'woo' => $woo, 'adapter' => 'woo_discounts_v1', ); return hash( 'sha256', (string) wp_json_encode( $payload ) ); } /** * On boot: if contract changed, mark all findings Needs verification (AC-015). * * @return bool True when a transition occurred. */ public function maybe_mark_needs_verification() { $current = $this->current_contract_fingerprint(); $stored = (string) get_option( self::OPTION_CALC_CONTRACT, '' ); if ( '' === $stored ) { update_option( self::OPTION_CALC_CONTRACT, $current, false ); $this->refresh_summary_cache(); return false; } if ( hash_equals( $stored, $current ) ) { return false; } $this->repo->mark_all_findings_freshness( Freshness::NEEDS_VERIFICATION ); $this->repo->mark_completed_scans_status( 'needs_verification' ); update_option( self::OPTION_CALC_CONTRACT, $current, false ); $this->refresh_summary_cache(); return true; } /** * Coupon material edit → that coupon stale; related product findings in same scans partially stale. * * @param int $coupon_id Coupon ID. * @return void */ public function on_coupon_material_change( $coupon_id ) { $coupon_id = (int) $coupon_id; if ( $coupon_id <= 0 ) { return; } $scan_ids = $this->repo->mark_entity_freshness( 'coupon', $coupon_id, Freshness::STALE ); if ( ! empty( $scan_ids ) ) { $this->repo->mark_scan_findings_freshness( $scan_ids, Freshness::PARTIAL_STALE, array( 'coupon' => $coupon_id ) // exclude already-stale coupon rows by leaving them; method skips non-fresh. ); } $this->refresh_summary_cache(); } /** * Product price / COGS change → product findings stale; sibling coupon findings partial_stale. * * @param int $product_id Product or variation ID used as entity_id. * @param int $variation_id Optional variation. * @return void */ public function on_product_material_change( $product_id, $variation_id = 0 ) { $product_id = (int) $product_id; $variation_id = (int) $variation_id; if ( $product_id <= 0 && $variation_id <= 0 ) { return; } $scan_ids = array(); if ( $variation_id > 0 ) { $scan_ids = array_merge( $scan_ids, $this->repo->mark_entity_freshness( 'variation', $variation_id, Freshness::STALE ) ); $scan_ids = array_merge( $scan_ids, $this->repo->mark_entity_freshness( 'product', $variation_id, Freshness::STALE ) ); } if ( $product_id > 0 ) { $scan_ids = array_merge( $scan_ids, $this->repo->mark_entity_freshness( 'product', $product_id, Freshness::STALE ) ); $scan_ids = array_merge( $scan_ids, $this->repo->mark_entity_freshness( 'variation', $product_id, Freshness::STALE ) ); } $scan_ids = array_values( array_unique( array_map( 'intval', $scan_ids ) ) ); if ( ! empty( $scan_ids ) ) { $this->repo->mark_scan_findings_freshness( $scan_ids, Freshness::PARTIAL_STALE ); } $this->refresh_summary_cache(); } /** * Global threshold edit → all financial findings stale (FR-090). * * @return void */ public function on_global_threshold_change() { $this->repo->mark_all_findings_freshness( Freshness::STALE, array( Freshness::NEEDS_VERIFICATION ) ); $this->refresh_summary_cache(); } /** * Dashboard summary counts. * * @return array{fresh:int,partial_stale:int,stale:int,needs_verification:int,coupons_stale:int,products_stale:int,summary_line:string} */ public function summarize() { $cached = get_option( self::OPTION_SUMMARY, null ); if ( is_array( $cached ) && isset( $cached['summary_line'] ) ) { return $cached; } return $this->refresh_summary_cache(); } /** * Recompute and persist summary. * * @return array */ public function refresh_summary_cache() { $counts = $this->repo->freshness_counts(); $entities = $this->repo->stale_entity_counts(); $partial = isset( $counts[ Freshness::PARTIAL_STALE ] ) ? (int) $counts[ Freshness::PARTIAL_STALE ] : 0; $stale = isset( $counts[ Freshness::STALE ] ) ? (int) $counts[ Freshness::STALE ] : 0; $needs = isset( $counts[ Freshness::NEEDS_VERIFICATION ] ) ? (int) $counts[ Freshness::NEEDS_VERIFICATION ] : 0; $fresh = isset( $counts[ Freshness::FRESH ] ) ? (int) $counts[ Freshness::FRESH ] : 0; $coupons = isset( $entities['coupon'] ) ? (int) $entities['coupon'] : 0; $products = ( isset( $entities['product'] ) ? (int) $entities['product'] : 0 ) + ( isset( $entities['variation'] ) ? (int) $entities['variation'] : 0 ); $line = __( 'All preflight findings are Fresh.', 'marginbeacon' ); if ( $needs > 0 ) { $line = sprintf( /* translators: %d: finding count */ __( 'Needs verification — %d findings require a verification preflight after a calculation contract change.', 'marginbeacon' ), $needs ); } elseif ( $partial > 0 || $stale > 0 || $coupons > 0 || $products > 0 ) { $line = sprintf( /* translators: 1: coupon count 2: product count */ __( 'Partially stale — %1$d coupons and %2$d products affected. Re-run preflight to refresh.', 'marginbeacon' ), $coupons, $products ); } $summary = array( 'fresh' => $fresh, 'partial_stale' => $partial, 'stale' => $stale, 'needs_verification' => $needs, 'coupons_stale' => $coupons, 'products_stale' => $products, 'summary_line' => $line, 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ); update_option( self::OPTION_SUMMARY, $summary, false ); return $summary; } }