prefix . self::TABLE_HISTORY;\n $charset = $wpdb->get_charset_collate();\n\n require_once ABSPATH . 'wp-admin/includes/upgrade.php';\n $sql = "CREATE TABLE {$table} (\n id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,\n product_id BIGINT(20) UNSIGNED NOT NULL,\n sku VARCHAR(200) NOT NULL DEFAULT '',\n supplier VARCHAR(64) NOT NULL DEFAULT '',\n regular_price VARCHAR(64) NOT NULL DEFAULT '',\n sale_price VARCHAR(64) NOT NULL DEFAULT '',\n context VARCHAR(64) NOT NULL DEFAULT '',\n imported_at DATETIME NOT NULL,\n PRIMARY KEY (id),\n KEY product_id (product_id),\n KEY sku (sku),\n KEY supplier (supplier),\n KEY imported_at (imported_at)\n ) {$charset};";\n dbDelta($sql);\n }\n\n private function internal_update_begin() { self::$INTERNAL_UPDATE = true; }\n private function internal_update_end() { self::$INTERNAL_UPDATE = false; }\n\n /**\n * Track price meta changes and write a single snapshot per product at shutdown.\n */\n public function on_postmeta_price_changed($meta_id, $object_id, $meta_key, $_meta_value) {\n if (self::$INTERNAL_UPDATE) return;\n $meta_key = (string)$meta_key;\n if (!in_array($meta_key, ['_regular_price', '_sale_price'], true)) return;\n\n $pid = (int)$object_id;\n if ($pid <= 0) return;\n $pt = get_post_type($pid);\n if ($pt !== 'product' && $pt !== 'product_variation') return;\n\n self::$PENDING_HISTORY_SNAPSHOTS[$pid] = true;\n\n if (!self::$SHUTDOWN_HOOKED) {\n self::$SHUTDOWN_HOOKED = true;\n add_action('shutdown', [$this,'flush_price_history_snapshots']);\n }\n }\n\n public function flush_price_history_snapshots() {\n if (empty(self::$PENDING_HISTORY_SNAPSHOTS)) return;\n\n $pids = array_keys(self::$PENDING_HISTORY_SNAPSHOTS);\n self::$PENDING_HISTORY_SNAPSHOTS = [];\n\n foreach ($pids as $pid) {\n $this->store_supplier_price_snapshot((int)$pid);\n }\n }\n\n private function store_supplier_price_snapshot(int $product_id) {\n // v2.1.4: generic WooCommerce price changes are audit-only.\n // They must never overwrite the trusted supplier-cost snapshot.\n if ($product_id <= 0) return;\n\n $product = wc_get_product($product_id);\n if (!$product) return;\n\n $sku = (string)$product->get_sku();\n $regular = (string)get_post_meta($product_id, '_regular_price', true);\n $sale = (string)get_post_meta($product_id, '_sale_price', true);\n\n $supplier = $this->detect_supplier_for_product($product_id);\n if ($supplier === '') $supplier = 'unknown';\n\n $context = 'observed_retail';\n if (wp_doing_cron()) $context .= '_cron';\n elseif (wp_doing_ajax()) $context .= '_ajax';\n elseif (is_admin()) $context .= '_admin';\n\n $this->append_price_history($product_id, $sku, $supplier, $regular, $sale, $context);\n }\n\n /**\n * Store supplier cost only when the caller is an explicit trusted supplier path.\n */\n private function store_trusted_supplier_snapshot(int $product_id, string $supplier, $regular, $sale = null, string $context = 'trusted_supplier') : bool {\n if ($product_id <= 0 || $supplier === '') return false;\n $product = wc_get_product($product_id);\n if (!$product) return false;\n\n if ($regular !== null && $regular !== '' && (!is_numeric($regular) || (float)$regular <= 0)) return false;\n if ($sale !== null && $sale !== '' && (!is_numeric($sale) || (float)$sale <= 0)) return false;\n if (($regular === null || $regular === '') && ($sale === null || $sale === '')) return false;\n\n $sku = (string)$product->get_sku();\n\n if ($regular !== null) update_post_meta($product_id, self::META_SUPP_REG, (string)$regular);\n if ($sale !== null) update_post_meta($product_id, self::META_SUPP_SALE, (string)$sale);\n update_post_meta($product_id, self::META_SUPP_SRC, $supplier);\n update_post_meta($product_id, self::META_SUPP_AT, time());\n update_post_meta($product_id, self::META_SUPP_TRUSTED, '1');\n\n $this->append_price_history(\n $product_id,\n $sku,\n $supplier,\n ($regular === null ? '' : (string)$regular),\n ($sale === null ? '' : (string)$sale),\n $context\n );\n return true;\n }\n\n public function record_supplier_cost_action($product_id, $supplier, $regular, $sale = null) {\n $product_id = (int)$product_id;\n $supplier = sanitize_key((string)$supplier);\n if ($product_id <= 0 || $supplier === '') return;\n\n $allowed = $this->get_suppliers();\n if (!isset($allowed[$supplier])) return;\n\n $ok = $this->store_trusted_supplier_snapshot($product_id, $supplier, $regular, $sale, 'trusted_import_hook');\n if (!$ok) return;\n\n $ms = $this->get_markup_settings();\n if (!empty($ms['enabled']) && !empty($ms['auto_on_import'])) {\n $this->apply_markup_to_product($product_id, $supplier);\n }\n }\n\n private function append_price_history(int $product_id, string $sku, string $supplier, string $regular, string $sale, string $context) : void {\n global $wpdb;\n $table = $wpdb->prefix . self::TABLE_HISTORY;\n $exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table));\n if ($exists !== $table) return;\n\n $wpdb->insert(\n $table,\n [\n 'product_id' => $product_id,\n 'sku' => $sku,\n 'supplier' => $supplier,\n 'regular_price' => $regular,\n 'sale_price' => $sale,\n 'context' => $context,\n 'imported_at' => current_time('mysql'),\n ],\n ['%d','%s','%s','%s','%s','%s','%s']\n );\n }\n\n private function get_target_ids(array $args) {\n $apply_to = $args['apply_to'] ?? 'all';\n $cat_match = $args['cat_match'] ?? 'any';\n\n $ids = [];\n\n if ($apply_to === 'sku') {\n $sku = sanitize_text_field($args['single_sku'] ?? '');\n if (!$sku) {\n return new WP_Error('wc_fpa_sku_required', 'SKU required');\n }\n $id = wc_get_product_id_by_sku($sku);\n if (!$id) {\n return new WP_Error('wc_fpa_sku_not_found', 'SKU not found');\n }\n $p = wc_get_product($id);\n if (!$p) {\n return new WP_Error('wc_fpa_product_not_found', 'Product not found');\n }\n\n // If a variable parent SKU is provided, do not price the parent directly.\n // Queue only its child variations; WooCommerce recalculates the parent price range.\n if ($p->is_type('variable')) {\n $ids = array_merge($ids, $p->get_children());\n } else {\n $ids = [$id];\n }\n\n } elseif ($apply_to === 'category') {\n $cats = array_map('intval', (array)($args['categories'] ?? []));\n if (!$cats) {\n return new WP_Error('wc_fpa_no_categories', 'No categories selected');\n }\n\n // Variations don’t carry product_cat terms, so query products then expand variable children.\n $product_ids = get_posts([\n 'post_type' => ['product'],\n 'post_status' => 'publish',\n 'posts_per_page' => -1,\n 'fields' => 'ids',\n 'tax_query' => [[\n 'taxonomy' => 'product_cat',\n 'field' => 'term_id',\n 'terms' => $cats,\n 'operator' => ($cat_match === 'all' ? 'AND' : 'IN')\n ]]\n ]);\n\n foreach ($product_ids as $pid) {\n $p = wc_get_product($pid);\n if (!$p) continue;\n if ($p->is_type('variable')) {\n $ids = array_merge($ids, $p->get_children());\n } else {\n $ids[] = $pid;\n }\n }\n\n } else {\n // All products: query parent products then expand variable children.\n $product_ids = get_posts([\n 'post_type' => ['product'],\n 'post_status' => 'publish',\n 'posts_per_page' => -1,\n 'fields' => 'ids'\n ]);\n\n foreach ($product_ids as $pid) {\n $p = wc_get_product($pid);\n if (!$p) continue;\n if ($p->is_type('variable')) {\n $ids = array_merge($ids, $p->get_children());\n } else {\n $ids[] = $pid;\n }\n }\n }\n\n $ids = array_values(array_unique(array_map('intval', $ids)));\n return $ids;\n }\n\n private function maybe_capture_original_prices($product, $force = false) {\n if (!$product) return;\n $id = $product->get_id();\n\n $reg = $product->get_regular_price();\n $sale = $product->get_sale_price();\n\n $has_reg = metadata_exists('post', $id, self::META_ORIG_REG);\n $has_sale = metadata_exists('post', $id, self::META_ORIG_SALE);\n\n if ($force || !$has_reg) {\n update_post_meta($id, self::META_ORIG_REG, (string)$reg);\n }\n if ($force || !$has_sale) {\n update_post_meta($id, self::META_ORIG_SALE, (string)$sale);\n }\n }\n\nprivate function get_suppliers() {\n // User-defined suppliers (JSON)\n $raw = (string) get_option(self::OPT_SUPPLIERS_JSON, '');\n $raw = trim($raw);\n $data = [];\n if ($raw !== '') {\n $decoded = json_decode($raw, true);\n if (is_array($decoded)) $data = $decoded;\n }\n\n // Normalize and drop invalid entries.\n $out = [];\n foreach ($data as $s) {\n if (!is_array($s)) continue;\n $key = isset($s['key']) ? sanitize_key($s['key']) : '';\n $name = isset($s['name']) ? sanitize_text_field($s['name']) : '';\n if ($key === '' || $name === '') continue;\n\n $out[$key] = [\n 'key' => $key,\n 'name' => $name,\n 'mode' => isset($s['mode']) ? sanitize_text_field($s['mode']) : 'per_sku', // per_sku\n 'auth' => isset($s['auth']) ? sanitize_text_field($s['auth']) : 'none', // none|bearer|header\n 'token' => isset($s['token']) ? (string) $s['token'] : '',\n 'header_name' => isset($s['header_name']) ? sanitize_text_field($s['header_name']) : '',\n 'api1_url' => isset($s['api1_url']) ? esc_url_raw((string)$s['api1_url']) : '',\n 'api1_role' => isset($s['api1_role']) ? sanitize_text_field($s['api1_role']) : 'regular', // regular|sale|both\n 'api2_url' => isset($s['api2_url']) ? esc_url_raw((string)$s['api2_url']) : '',\n 'api2_role' => isset($s['api2_role']) ? sanitize_text_field($s['api2_role']) : 'sale',\n 'sku_path' => isset($s['sku_path']) ? sanitize_text_field($s['sku_path']) : 'sku',\n 'regular_path' => isset($s['regular_path']) ? sanitize_text_field($s['regular_path']) : 'regular_price',\n 'sale_path' => isset($s['sale_path']) ? sanitize_text_field($s['sale_path']) : 'sale_price',\n ];\n }\n\n // Auto suppliers from importer plugins (do not overwrite user-defined keys)\n foreach ($this->get_auto_suppliers() as $k => $s) {\n if (!isset($out[$k])) $out[$k] = $s;\n }\n\n return $out;\n}\n\nprivate function get_auto_suppliers() {\n $auto = [];\n\n // UNEEK All-in-One Sync\n $uneek = get_option('uneek_aio_opts', []);\n if (is_array($uneek)) {\n $products_url = trim((string)($uneek['api_products_url'] ?? ''));\n $api_base = trim((string)($uneek['api_base'] ?? ''));\n $token = (string)($uneek['api_token'] ?? '');\n $auth_type = (string)($uneek['api_auth_type'] ?? '');\n if ($auth_type === '') {\n $auth_type = (!empty($uneek['api_basic_user']) || !empty($uneek['api_basic_pass'])) ? 'basic' : (!empty($token) ? 'bearer' : 'none');\n }\n\n if ($products_url !== '' || $api_base !== '') {\n $auto[self::SUPPLIER_UNEEK_AUTO] = [\n 'key' => self::SUPPLIER_UNEEK_AUTO,\n 'name' => 'UNEEK (auto)',\n 'mode' => 'uneek_list', // one request returns list of rows\n 'products_url' => $products_url,\n 'api_base' => $api_base,\n 'token' => $token,\n 'auth_type' => $auth_type, // bearer|basic|none\n 'basic_user' => (string)($uneek['api_basic_user'] ?? ''),\n 'basic_pass' => (string)($uneek['api_basic_pass'] ?? ''),\n // Default field mapping for UNEEK feeds\n 'sku_fields' => ['StyleCode','ShortCode','ProductCode','ParentStyleCode'],\n 'regular_fields' => ['MyPrice','PriceSingle','price','RegularPrice'],\n 'sale_fields' => ['SalePrice','sale_price'],\n // Brand term used to identify UNEEK products (if set)\n 'brand_taxonomy' => trim((string)($uneek['brand_taxonomy'] ?? 'product_brand')),\n 'brand_term_slug' => trim((string)($uneek['brand_term_slug'] ?? 'uneek-clothing')),\n ];\n }\n }\n\n // Ralawise Full Sync\n $ral = get_option('ralawise_full_sync_options', []);\n if (is_array($ral)) {\n $base = rtrim((string)($ral['base_url'] ?? 'https://api.ralawise.com'), '/');\n $user = trim((string)($ral['username'] ?? ''));\n $pass = (string)($ral['password'] ?? '');\n if ($base !== '' && $user !== '' && $pass !== '') {\n $auto[self::SUPPLIER_RALAWISE_AUTO] = [\n 'key' => self::SUPPLIER_RALAWISE_AUTO,\n 'name' => 'Ralawise (auto)',\n // Ralawise pricing is imported via CSV by the Ralawise Full Sync plugin.\n // For price reset we treat that CSV as the supplier truth source.\n 'mode' => 'ralawise_csv', // last imported variations CSV\n 'base_url' => $base,\n 'username' => $user,\n 'password' => $pass,\n // CSV column hints (we auto-detect from header)\n 'sku_cols' => ['sku', 'SKU', 'Sku'],\n 'regular_cols' => ['regular_price', 'price', 'Regular Price', 'regular price'],\n 'sale_cols' => ['sale_price', 'sale', 'Sale Price', 'sale price'],\n ];\n }\n }\n\n return $auto;\n}\n\nprivate function detect_supplier_for_product($product_id) {\n // Explicit supplier meta always wins when present. This prevents Ralawise products being routed to Uneek just because a brand/category term is wrong.\n $supplier_meta = strtolower(trim((string)get_post_meta($product_id, '_supplier', true)));\n if ($supplier_meta === 'ralawise') return self::SUPPLIER_RALAWISE_AUTO;\n if ($supplier_meta === 'uneek') return self::SUPPLIER_UNEEK_AUTO;\n\n // Ralawise: explicit managed flag\n $is_ral = get_post_meta($product_id, '_ralawise_managed', true);\n if ((string)$is_ral === '1') return self::SUPPLIER_RALAWISE_AUTO;\n\n // UNEEK: explicit managed flag\n $is_uneek = get_post_meta($product_id, '_uneek_managed', true);\n if ((string)$is_uneek === '1') return self::SUPPLIER_UNEEK_AUTO;\n\n // Variations often inherit supplier flags from their parent variable product.\n $parent_id = (int) wp_get_post_parent_id($product_id);\n if ($parent_id > 0) {\n $parent_supplier_meta = strtolower(trim((string)get_post_meta($parent_id, '_supplier', true)));\n if ($parent_supplier_meta === 'ralawise') return self::SUPPLIER_RALAWISE_AUTO;\n if ($parent_supplier_meta === 'uneek') return self::SUPPLIER_UNEEK_AUTO;\n if ((string)get_post_meta($parent_id, '_ralawise_managed', true) === '1') return self::SUPPLIER_RALAWISE_AUTO;\n if ((string)get_post_meta($parent_id, '_uneek_managed', true) === '1') return self::SUPPLIER_UNEEK_AUTO;\n }\n\n // UNEEK: by brand taxonomy term (as configured in UNEEK plugin)\n $uneek = $this->get_auto_suppliers()[self::SUPPLIER_UNEEK_AUTO] ?? null;\n if ($uneek && !empty($uneek['brand_taxonomy']) && !empty($uneek['brand_term_slug'])) {\n $tax = (string)$uneek['brand_taxonomy'];\n $slug = (string)$uneek['brand_term_slug'];\n if (taxonomy_exists($tax)) {\n $terms = wp_get_object_terms($product_id, $tax, ['fields'=>'slugs']);\n if (!is_wp_error($terms) && is_array($terms) && in_array($slug, $terms, true)) {\n return self::SUPPLIER_UNEEK_AUTO;\n }\n }\n }\n\n return '';\n}\n\n\n\n/**\n * Check whether a supplier price map contains a WooCommerce SKU.\n * Ralawise often stores the parent/style SKU while WooCommerce variations use longer SKUs,\n * so Ralawise uses exact match first, then longest-prefix match.\n */\nprivate function supplier_price_map_has_sku($supplier_key, $sku, $cache) {\n $sku = trim((string)$sku);\n if ($sku === '') return false;\n\n if ($supplier_key === self::SUPPLIER_RALAWISE_AUTO) {\n $map = is_array($cache['ralawise_map'] ?? null) ? $cache['ralawise_map'] : [];\n if (!$map) return false;\n if (isset($map[$sku])) return true;\n for ($i = strlen($sku); $i >= 2; $i--) {\n if (isset($map[substr($sku, 0, $i)])) return true;\n }\n return false;\n }\n\n if ($supplier_key === self::SUPPLIER_UNEEK_AUTO) {\n $map = is_array($cache['uneek_map'] ?? null) ? $cache['uneek_map'] : [];\n if (!$map) return false;\n return isset($map[$sku]);\n }\n\n return false;\n}\n\n/**\n * More reliable Auto supplier routing.\n * First trust an explicit product flag/brand only if the SKU exists in that supplier map.\n * If not, fall back to checking the loaded supplier price maps. Ralawise is checked first\n * because its variation SKUs often need prefix matching, e.g. AD003BLACL -> AD003.\n */\nprivate function resolve_auto_supplier_for_product($product_id, $sku, $cache) {\n $sku = trim((string)$sku);\n $detected = $this->detect_supplier_for_product((int)$product_id);\n\n if ($detected !== '' && $this->supplier_price_map_has_sku($detected, $sku, $cache)) {\n return $detected;\n }\n\n if ($this->supplier_price_map_has_sku(self::SUPPLIER_RALAWISE_AUTO, $sku, $cache)) {\n return self::SUPPLIER_RALAWISE_AUTO;\n }\n\n if ($this->supplier_price_map_has_sku(self::SUPPLIER_UNEEK_AUTO, $sku, $cache)) {\n return self::SUPPLIER_UNEEK_AUTO;\n }\n\n // If there is no map available, fall back to the old detection method.\n if ($detected !== '') return $detected;\n\n return '';\n}\n\n// ------------------------------\n// Per-product selling price lock\n// ------------------------------\n\nprivate function is_price_locked(int $product_id) : bool {\n $v = get_post_meta($product_id, self::META_LOCK_SELLING, true);\n if ((string)$v === '1') return true;\n\n // Variations can inherit lock from parent.\n $parent_id = wp_get_post_parent_id($product_id);\n if ($parent_id && $parent_id !== $product_id) {\n $pv = get_post_meta($parent_id, self::META_LOCK_SELLING, true);\n if ((string)$pv === '1') return true;\n }\n return false;\n}\n\npublic function product_lock_field() {\n global $post;\n if (!$post || $post->post_type !== 'product') return;\n woocommerce_wp_checkbox([\n 'id' => self::META_LOCK_SELLING,\n 'label' => 'Lock selling price (prevent reset/markup)',\n 'desc_tip' => true,\n 'description' => 'When enabled, this plugin will not change this product’s prices during resets or markup recalculations. Variations inherit the parent lock unless they have their own lock.',\n 'value' => get_post_meta($post->ID, self::META_LOCK_SELLING, true),\n ]);\n}\n\npublic function save_product_lock_field($post_id) {\n $val = isset($_POST[self::META_LOCK_SELLING]) ? '1' : '0';\n update_post_meta($post_id, self::META_LOCK_SELLING, $val);\n}\n\npublic function variation_lock_field($loop, $variation_data, $variation) {\n $vid = is_object($variation) ? (int)$variation->ID : 0;\n woocommerce_wp_checkbox([\n 'id' => self::META_LOCK_SELLING . "[$loop]",\n 'label' => 'Lock selling price',\n 'desc_tip' => true,\n 'description' => 'Prevents this plugin from changing this variation’s prices during reset/markup.',\n 'value' => ($vid ? get_post_meta($vid, self::META_LOCK_SELLING, true) : ''),\n ]);\n}\n\npublic function save_variation_lock_field($variation_id, $i) {\n $val = (isset($_POST[self::META_LOCK_SELLING]) && isset($_POST[self::META_LOCK_SELLING][$i])) ? '1' : '0';\n update_post_meta($variation_id, self::META_LOCK_SELLING, $val);\n}\n\n// ------------------------------\n// Markup (idempotent) - per supplier percentages\n// ------------------------------\n\nprivate function get_markup_settings() {\n $d = [\n 'enabled' => 1,\n 'auto_on_import' => 1,\n 'auto_after_reset' => 0,\n 'apply_sale' => 0,\n 'ending' => 'none',\n 'by_supplier' => [\n self::SUPPLIER_UNEEK_AUTO => 0,\n self::SUPPLIER_RALAWISE_AUTO => 0,\n 'unknown' => 0,\n ],\n ];\n $raw = get_option(self::OPT_MARKUP_SETTINGS, []);\n if (!is_array($raw)) return $d;\n $out = $d;\n foreach ($d as $k => $v) {\n if (array_key_exists($k, $raw)) $out[$k] = $raw[$k];\n }\n if (!is_array($out['by_supplier'] ?? null)) $out['by_supplier'] = $d['by_supplier'];\n return $out;\n}\n\nprivate function get_markup_percent_for_supplier($supplier_key) {\n $ms = $this->get_markup_settings();\n $by = is_array($ms['by_supplier'] ?? null) ? $ms['by_supplier'] : [];\n if ($supplier_key && array_key_exists($supplier_key, $by)) return (float)$by[$supplier_key];\n return (float)($by['unknown'] ?? 0);\n}\n\nprivate function apply_price_ending($price, $ending) {\n $price = (float)$price;\n if ($ending === 'none') return $price;\n $ending = (string)$ending;\n if (!preg_match('/^\.(\d\d)$/', $ending, $m)) return $price;\n $cents = (int)$m[1];\n $base = floor($price);\n $new = $base + ($cents / 100);\n // If we rounded down below original, bump by 1.\n if ($new < $price) $new = ($base + 1) + ($cents / 100);\n return $new;\n}\n\nprivate function format_price($price) {\n // Keep WooCommerce-friendly numeric string.\n $price = (float)$price;\n return wc_format_decimal($price, wc_get_price_decimals());\n}\n\nprivate function calculate_markup_retail_from_cost($supplier_cost, $supplier_key) {\n if ($supplier_cost === '' || $supplier_cost === null || !is_numeric($supplier_cost) || (float)$supplier_cost <= 0) return '';\n $ms = $this->get_markup_settings();\n $pct = $this->get_markup_percent_for_supplier((string)$supplier_key);\n $value = (float)$supplier_cost * (1 + ($pct / 100.0));\n $value = $this->apply_price_ending($value, (string)($ms['ending'] ?? 'none'));\n return $this->format_price($value);\n}\n\nprivate function calculate_markup_prices_from_trusted_cost($regular_cost, $sale_cost, $supplier_key) {\n $ms = $this->get_markup_settings();\n $regular = $this->calculate_markup_retail_from_cost($regular_cost, $supplier_key);\n $sale = '';\n if (!empty($ms['apply_sale']) && $sale_cost !== null && $sale_cost !== '') {\n $sale = $this->calculate_markup_retail_from_cost($sale_cost, $supplier_key);\n }\n return ['regular' => $regular, 'sale' => $sale];\n}\nprivate function apply_markup_to_product($product_id, $supplier_key = '') {\n // Respect selling price lock.\n if ($this->is_price_locked((int)$product_id)) {\n return false;\n }\n $ms = $this->get_markup_settings();\n if (empty($ms['enabled'])) return false;\n\n // v2.1.4 safety gate: legacy/generic snapshots cannot drive retail markup.\n if ((string)get_post_meta($product_id, self::META_SUPP_TRUSTED, true) !== '1') return false;\n\n if ($supplier_key === '') {\n $supplier_key = get_post_meta($product_id, self::META_SUPP_SRC, true);\n $supplier_key = (string)$supplier_key;\n if ($supplier_key === '') $supplier_key = $this->detect_supplier_for_product($product_id);\n if ($supplier_key === '') $supplier_key = 'unknown';\n }\n\n $pct = $this->get_markup_percent_for_supplier($supplier_key);\n // If percent is zero and sale not being handled, still record meta for visibility.\n\n $supp_reg = get_post_meta($product_id, self::META_SUPP_REG, true);\n $supp_sale = get_post_meta($product_id, self::META_SUPP_SALE, true);\n if ($supp_reg === '' && $supp_sale === '') return false; // nothing to base from\n if ($supp_reg !== '' && (!is_numeric($supp_reg) || (float)$supp_reg <= 0)) return false;\n if ($supp_sale !== '' && (!is_numeric($supp_sale) || (float)$supp_sale <= 0)) return false;\n\n $p = wc_get_product($product_id);\n if (!$p) return false;\n\n $new_reg = '';\n $new_sale = '';\n\n if ($supp_reg !== '') {\n $new_reg = (float)$supp_reg * (1 + ($pct / 100.0));\n $new_reg = $this->apply_price_ending($new_reg, (string)$ms['ending']);\n $new_reg = $this->format_price($new_reg);\n }\n\n if (!empty($ms['apply_sale']) && $supp_sale !== '') {\n $new_sale = (float)$supp_sale * (1 + ($pct / 100.0));\n $new_sale = $this->apply_price_ending($new_sale, (string)$ms['ending']);\n $new_sale = $this->format_price($new_sale);\n } elseif (!empty($ms['apply_sale']) && $supp_sale === '') {\n $new_sale = '';\n }\n\n $this->internal_update_begin();\n try {\n if ($new_reg !== '') $p->set_regular_price($new_reg);\n if (!empty($ms['apply_sale'])) {\n $p->set_sale_price($new_sale);\n }\n $p->save();\n update_post_meta($product_id, self::META_MARKUP_PCT, (string)$pct);\n update_post_meta($product_id, self::META_MARKUP_AT, time());\n } finally {\n $this->internal_update_end();\n }\n return true;\n}\n\npublic function save_markup_settings() {\n if (!current_user_can(self::CAP)) wp_die('No permission');\n check_admin_referer('wc_fpa_save_markup');\n\n $enabled = !empty($_POST['markup_enabled']) ? 1 : 0;\n $auto_on_import = !empty($_POST['markup_auto_on_import']) ? 1 : 0;\n $auto_after_reset = !empty($_POST['markup_auto_after_reset']) ? 1 : 0;\n $apply_sale = !empty($_POST['markup_apply_sale']) ? 1 : 0;\n $ending = isset($_POST['markup_ending']) ? sanitize_text_field((string)$_POST['markup_ending']) : 'none';\n\n $by = [];\n $by[self::SUPPLIER_UNEEK_AUTO] = isset($_POST['pct_uneek']) ? (float)$_POST['pct_uneek'] : 0;\n $by[self::SUPPLIER_RALAWISE_AUTO] = isset($_POST['pct_ralawise']) ? (float)$_POST['pct_ralawise'] : 0;\n $by['unknown'] = isset($_POST['pct_unknown']) ? (float)$_POST['pct_unknown'] : 0;\n\n update_option(self::OPT_MARKUP_SETTINGS, [\n 'enabled' => $enabled,\n 'auto_on_import' => $auto_on_import,\n 'auto_after_reset' => $auto_after_reset,\n 'apply_sale' => $apply_sale,\n 'ending' => $ending,\n 'by_supplier' => $by,\n ], false);\n\n wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&markup_saved=1'));\n exit;\n}\n\n\nprivate function report_file_create($job_id) {\n $up = wp_upload_dir();\n $dir = trailingslashit($up['basedir']) . self::REPORT_SUBDIR;\n if (!file_exists($dir)) {\n wp_mkdir_p($dir);\n }\n $fn = 'wc-fpa-reset-' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$job_id) . '-' . gmdate('Ymd-His') . '.csv';\n $path = trailingslashit($dir) . $fn;\n $fh = @fopen($path, 'w');\n if ($fh) {\n fputcsv($fh, ['timestamp_utc','product_id','sku','supplier_key','api_regular','api_sale','woo_regular_before','woo_sale_before','woo_regular_after','woo_sale_after','hash_after','status','detail']);\n fclose($fh);\n }\n return $path;\n}\n\nprivate function report_file_append($path, $row) {\n if (!$path) return;\n $fh = @fopen($path, 'a');\n if (!$fh) return;\n fputcsv($fh, $row);\n fclose($fh);\n}\n\nprivate function report_file_url($path) {\n $up = wp_upload_dir();\n $base = trailingslashit($up['basedir']);\n if (strpos($path, $base) !== 0) return '';\n $rel = ltrim(substr($path, strlen($base)), '/');\n return trailingslashit($up['baseurl']) . str_replace(DIRECTORY_SEPARATOR, '/', $rel);\n}\n\nprivate function compute_price_hash($regular, $sale) {\n $regular = (string)$regular;\n $sale = (string)$sale;\n return substr(sha1($regular . '|' . $sale), 0, 12);\n}\n\nprivate function ralawise_build_price_map_from_csv($supplier) {\n // The Ralawise Full Sync plugin stores the last staged variations CSV path in:\n // option ralawise_full_sync_state_variations => ['file' => '/path/to/file.csv', ...]\n $state = get_option('ralawise_full_sync_state_variations', []);\n $file = is_array($state) ? (string)($state['file'] ?? '') : '';\n $file = trim($file);\n if ($file === '' || !file_exists($file)) {\n return new WP_Error('wc_fpa_ral_csv_missing', 'Ralawise variations CSV not found. Run the Ralawise Full Sync (Variations) stage/import first.');\n }\n\n $fh = @fopen($file, 'r');\n if (!$fh) {\n return new WP_Error('wc_fpa_ral_csv_open', 'Unable to open Ralawise CSV file.');\n }\n\n // Auto-detect delimiter. Ralawise staged variation feeds are commonly TAB-separated,\n // but this also supports comma and semicolon files.\n $first_line = fgets($fh);\n if ($first_line === false) {\n fclose($fh);\n return new WP_Error('wc_fpa_ral_csv_header', 'Ralawise CSV header missing/invalid.');\n }\n $delimiters = ["\t", ';', ','];\n $delimiter = ',';\n $best_count = -1;\n foreach ($delimiters as $d) {\n $count = substr_count($first_line, $d);\n if ($count > $best_count) { $best_count = $count; $delimiter = $d; }\n }\n $header = str_getcsv($first_line, $delimiter);\n if (!is_array($header)) {\n fclose($fh);\n return new WP_Error('wc_fpa_ral_csv_header', 'Ralawise CSV header missing/invalid.');\n }\n // Normalize header map\n $map = [];\n foreach ($header as $i => $h) {\n $h = trim((string)$h);\n if ($h === '') continue;\n $map[strtolower($h)] = (int)$i;\n }\n\n $sku_idx = null;\n foreach ((array)($supplier['sku_cols'] ?? ['sku']) as $c) {\n $k = strtolower(trim((string)$c));\n if ($k !== '' && array_key_exists($k, $map)) { $sku_idx = $map[$k]; break; }\n }\n if ($sku_idx === null && array_key_exists('sku', $map)) $sku_idx = $map['sku'];\n\n $reg_idx = null;\n foreach ((array)($supplier['regular_cols'] ?? ['regular_price']) as $c) {\n $k = strtolower(trim((string)$c));\n if ($k !== '' && array_key_exists($k, $map)) { $reg_idx = $map[$k]; break; }\n }\n\n $sale_idx = null;\n foreach ((array)($supplier['sale_cols'] ?? ['sale_price']) as $c) {\n $k = strtolower(trim((string)$c));\n if ($k !== '' && array_key_exists($k, $map)) { $sale_idx = $map[$k]; break; }\n }\n\n if ($sku_idx === null || $reg_idx === null) {\n fclose($fh);\n return new WP_Error('wc_fpa_ral_csv_cols', 'Ralawise CSV is missing required columns (sku and regular_price).');\n }\n\n $out = [];\n while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {\n if (!is_array($row)) continue;\n $sku = isset($row[$sku_idx]) ? trim((string)$row[$sku_idx]) : '';\n if ($sku === '') continue;\n $reg = isset($row[$reg_idx]) ? trim((string)$row[$reg_idx]) : '';\n $sale = ($sale_idx !== null && isset($row[$sale_idx])) ? trim((string)$row[$sale_idx]) : null;\n if ($reg === '' && ($sale === null || $sale === '')) continue;\n $out[$sku] = [\n 'regular' => ($reg !== '' && is_numeric($reg)) ? (string)$reg : null,\n // allow empty string to clear sale\n 'sale' => ($sale === null ? null : ($sale === '' ? '' : (is_numeric($sale) ? (string)$sale : ''))),\n ];\n }\n fclose($fh);\n\n if (empty($out)) {\n return new WP_Error('wc_fpa_ral_csv_empty', 'Ralawise CSV loaded but no prices could be mapped.');\n }\n return $out;\n}\n\nprivate function uneek_fetch_price_map($supplier) {\n $url = trim((string)($supplier['products_url'] ?? ''));\n if ($url === '' && !empty($supplier['api_base']) && !empty($supplier['products_path'])) {\n $url = rtrim((string)$supplier['api_base'], '/') . '/' . ltrim((string)$supplier['products_path'], '/');\n }\n if ($url === '') return new WP_Error('wc_fpa_uneek_no_url', 'UNEEK products API URL not configured');\n\n $headers = [\n 'Accept' => 'application/json',\n 'Cache-Control' => 'no-cache',\n ];\n $auth = (string)($supplier['auth_type'] ?? 'none');\n $token = (string)($supplier['token'] ?? '');\n if ($auth === 'bearer' && $token !== '') {\n $headers['Authorization'] = (stripos($token,'Bearer ')===0) ? $token : ('Bearer '.$token);\n } elseif ($auth === 'basic') {\n $u = (string)($supplier['basic_user'] ?? '');\n $p = (string)($supplier['basic_pass'] ?? '');\n $headers['Authorization'] = 'Basic ' . base64_encode($u . ':' . $p);\n }\n\n $resp = wp_remote_get($url, ['timeout'=>60, 'headers'=>$headers]);\n if (is_wp_error($resp)) return $resp;\n $code = (int) wp_remote_retrieve_response_code($resp);\n $body = (string) wp_remote_retrieve_body($resp);\n if ($code < 200 || $code >= 300 || $body === '') {\n return new WP_Error('wc_fpa_uneek_http', 'UNEEK API HTTP '.$code);\n }\n\n $body = ltrim($body);\n if (strncmp($body, "\xEF\xBB\xBF", 3) === 0) $body = substr($body, 3);\n $data = json_decode($body, true);\n if (is_string($data)) {\n $inner = trim($data);\n if ($inner !== '' && ($inner[0] === '[' || $inner[0] === '{')) $data = json_decode($inner, true);\n }\n if (is_array($data) && isset($data['value']) && is_array($data['value'])) $data = $data['value'];\n if (!is_array($data)) return new WP_Error('wc_fpa_uneek_json', 'UNEEK API JSON decode failed');\n\n $sku_fields = (array)($supplier['sku_fields'] ?? ['StyleCode','ShortCode']);\n $reg_fields = (array)($supplier['regular_fields'] ?? ['MyPrice','PriceSingle']);\n $sale_fields = (array)($supplier['sale_fields'] ?? []);\n\n $map = [];\n foreach ($data as $row) {\n if (!is_array($row)) continue;\n\n $sku = '';\n foreach ($sku_fields as $f) {\n if (isset($row[$f]) && trim((string)$row[$f]) !== '') { $sku = trim((string)$row[$f]); break; }\n }\n if ($sku === '') continue;\n\n $reg = null;\n foreach ($reg_fields as $f) {\n if (isset($row[$f]) && $row[$f] !== '' && is_numeric($row[$f])) { $reg = (string)$row[$f]; break; }\n }\n $sale = null;\n foreach ($sale_fields as $f) {\n if (isset($row[$f]) && $row[$f] !== '' && is_numeric($row[$f])) { $sale = (string)$row[$f]; break; }\n }\n\n if ($reg === null && $sale === null) continue;\n $map[$sku] = ['regular'=>$reg, 'sale'=>$sale];\n }\n\n if (empty($map)) {\n return new WP_Error('wc_fpa_uneek_empty', 'UNEEK API returned no mappable prices');\n }\n\n return $map;\n}\n\nprivate function ralawise_get_token($supplier) {\n $base = rtrim((string)($supplier['base_url'] ?? ''), '/');\n if ($base === '') return new WP_Error('wc_fpa_ral_no_base', 'Ralawise base URL missing');\n $user = (string)($supplier['username'] ?? '');\n $pass = (string)($supplier['password'] ?? '');\n if ($user === '' || $pass === '') return new WP_Error('wc_fpa_ral_no_creds', 'Ralawise credentials missing');\n\n $resp = wp_remote_post($base . '/v1/login', [\n 'timeout' => 30,\n 'headers' => ['Content-Type'=>'application/json', 'Accept'=>'application/json'],\n 'body' => wp_json_encode(['username'=>$user, 'password'=>$pass]),\n ]);\n if (is_wp_error($resp)) return $resp;\n $code = (int) wp_remote_retrieve_response_code($resp);\n $body = (string) wp_remote_retrieve_body($resp);\n if ($code < 200 || $code >= 300) return new WP_Error('wc_fpa_ral_login_http', 'Ralawise login HTTP '.$code);\n $json = json_decode($body, true);\n if (!is_array($json)) return new WP_Error('wc_fpa_ral_login_json', 'Ralawise login JSON decode failed');\n\n // Try common token keys\n $token = $json['token'] ?? ($json['access_token'] ?? ($json['data']['token'] ?? null));\n if (!$token || !is_string($token)) return new WP_Error('wc_fpa_ral_no_token', 'Ralawise token not found in login response');\n return $token;\n}\n\nprivate function ralawise_fetch_inventory_price($supplier, $sku, $token) {\n $base = rtrim((string)($supplier['base_url'] ?? ''), '/');\n $url = $base . '/v1/inventory/' . rawurlencode((string)$sku);\n\n $resp = wp_remote_get($url, [\n 'timeout' => 30,\n 'headers' => [\n 'Accept' => 'application/json',\n 'Authorization' => 'Bearer ' . $token,\n 'Content-Type' => 'application/json',\n ],\n ]);\n if (is_wp_error($resp)) return $resp;\n $code = (int) wp_remote_retrieve_response_code($resp);\n $body = (string) wp_remote_retrieve_body($resp);\n if ($code < 200 || $code >= 300 || $body === '') return new WP_Error('wc_fpa_ral_inv_http', 'Ralawise inventory HTTP '.$code);\n\n $json = json_decode($body, true);\n if (!is_array($json)) return new WP_Error('wc_fpa_ral_inv_json', 'Ralawise inventory JSON decode failed');\n\n $reg_path = (string)($supplier['regular_path'] ?? 'price');\n $sale_path = (string)($supplier['sale_path'] ?? 'sale_price');\n\n $reg = $this->get_json_path_value($json, $reg_path);\n $sale = $this->get_json_path_value($json, $sale_path);\n\n $out = ['regular'=>null,'sale'=>null];\n if ($reg !== null && $reg !== '' && is_numeric($reg)) $out['regular'] = (string)$reg;\n if ($sale !== null) {\n // allow empty to clear\n $out['sale'] = ($sale === '' ? '' : (is_numeric($sale) ? (string)$sale : ''));\n }\n\n if ($out['regular'] === null && $out['sale'] === null) return new WP_Error('wc_fpa_ral_no_price', 'No price returned from Ralawise API');\n\n return $out;\n}\n\n private function get_json_path_value($data, $path) {\n if (!is_array($data) && !is_object($data)) return null;\n $path = trim((string)$path);\n if ($path === '') return null;\n\n $parts = explode('.', $path);\n $cur = $data;\n foreach ($parts as $p) {\n $p = trim($p);\n if ($p === '') return null;\n\n // Array index: field[0]\n if (preg_match('/^(.+)\[(\d+)\]$/', $p, $m)) {\n $field = $m[1];\n $idx = (int)$m[2];\n if (is_array($cur) && array_key_exists($field, $cur)) {\n $cur = $cur[$field];\n } elseif (is_object($cur) && isset($cur->$field)) {\n $cur = $cur->$field;\n } else {\n return null;\n }\n if (is_array($cur) && array_key_exists($idx, $cur)) {\n $cur = $cur[$idx];\n } else {\n return null;\n }\n continue;\n }\n\n if (is_array($cur) && array_key_exists($p, $cur)) {\n $cur = $cur[$p];\n } elseif (is_object($cur) && isset($cur->$p)) {\n $cur = $cur->$p;\n } else {\n return null;\n }\n }\n return $cur;\n }\n\n private function api_headers_for_supplier($supplier) {\n $headers = [\n 'Accept' => 'application/json',\n ];\n $auth = $supplier['auth'] ?? 'none';\n $token = (string)($supplier['token'] ?? '');\n\n if ($auth === 'bearer' && $token !== '') {\n $headers['Authorization'] = 'Bearer ' . $token;\n } elseif ($auth === 'header') {\n $hn = (string)($supplier['header_name'] ?? '');\n if ($hn !== '' && $token !== '') {\n $headers[$hn] = $token;\n }\n }\n return $headers;\n }\n\n \nprivate function fetch_supplier_price($supplier, $sku) {\n $sku = (string)$sku;\n if ($sku === '') return new WP_Error('wc_fpa_no_sku', 'Missing SKU');\n\n $mode = (string)($supplier['mode'] ?? 'per_sku');\n\n // UNEEK list mode: supplier provides a pre-fetched price map in _price_map\n if ($mode === 'uneek_list') {\n $map = $supplier['_price_map'] ?? null;\n if (!is_array($map)) return new WP_Error('wc_fpa_uneek_no_map', 'UNEEK price map missing');\n if (!isset($map[$sku])) return new WP_Error('wc_fpa_uneek_sku_missing', 'SKU not found in UNEEK API');\n $row = $map[$sku];\n return [\n 'regular' => array_key_exists('regular', $row) ? $row['regular'] : null,\n 'sale' => array_key_exists('sale', $row) ? $row['sale'] : null,\n ];\n }\n\n // Ralawise CSV mode: supplier provides a pre-fetched price map in _price_map\n if ($mode === 'ralawise_csv') {\n $map = $supplier['_price_map'] ?? null;\n if (!is_array($map)) return new WP_Error('wc_fpa_ral_no_map', 'Ralawise price map missing');\n // Ralawise CSV frequently stores *parent/style* SKU (e.g. B010F) while WooCommerce\n // variations use concatenated SKUs (e.g. B010FSOROS). To avoid skipping, we:\n // 1) try exact match\n // 2) try longest prefix match found in the map\n $lookup_sku = null;\n if (isset($map[$sku])) {\n $lookup_sku = $sku;\n } else {\n $len = strlen($sku);\n for ($i = $len; $i >= 2; $i--) {\n $cand = substr($sku, 0, $i);\n if (isset($map[$cand])) {\n $lookup_sku = $cand;\n break;\n }\n }\n }\n if ($lookup_sku === null) return new WP_Error('wc_fpa_ral_sku_missing', 'SKU not found in Ralawise CSV');\n $row = $map[$lookup_sku];\n return [\n 'regular' => array_key_exists('regular', $row) ? $row['regular'] : null,\n 'sale' => array_key_exists('sale', $row) ? $row['sale'] : null,\n ];\n }\n\n // Ralawise inventory mode: needs a token in _token\n if ($mode === 'ralawise_inventory') {\n $token = (string)($supplier['_token'] ?? '');\n if ($token === '') return new WP_Error('wc_fpa_ral_no_token', 'Ralawise token missing');\n return $this->ralawise_fetch_inventory_price($supplier, $sku, $token);\n }\n\n // Default per-SKU endpoints (user-defined suppliers JSON)\n $result = [\n 'regular' => null,\n 'sale' => null,\n ];\n\n $headers = $this->api_headers_for_supplier($supplier);\n\n $endpoints = [\n ['url' => (string)($supplier['api1_url'] ?? ''), 'role' => (string)($supplier['api1_role'] ?? 'regular')],\n ['url' => (string)($supplier['api2_url'] ?? ''), 'role' => (string)($supplier['api2_role'] ?? 'sale')],\n ];\n\n foreach ($endpoints as $ep) {\n $tpl = trim($ep['url']);\n if ($tpl === '') continue;\n $url = str_replace('{sku}', rawurlencode($sku), $tpl);\n\n $resp = wp_remote_get($url, [\n 'timeout' => 20,\n 'headers' => $headers,\n ]);\n if (is_wp_error($resp)) {\n // Try next endpoint; we want best-effort.\n continue;\n }\n\n $code = (int) wp_remote_retrieve_response_code($resp);\n $body = (string) wp_remote_retrieve_body($resp);\n if ($code < 200 || $code >= 300 || $body === '') {\n continue;\n }\n\n $json = json_decode($body, true);\n if (!is_array($json)) {\n continue;\n }\n\n $role = $ep['role'];\n $reg_path = (string)($supplier['regular_path'] ?? 'regular_price');\n $sale_path = (string)($supplier['sale_path'] ?? 'sale_price');\n\n if ($role === 'regular' || $role === 'both') {\n $val = $this->get_json_path_value($json, $reg_path);\n if ($val !== null && $val !== '' && is_numeric($val)) {\n $result['regular'] = (string)$val;\n }\n }\n if ($role === 'sale' || $role === 'both') {\n $val = $this->get_json_path_value($json, $sale_path);\n // Allow empty sale to clear.\n if ($val !== null) {\n $result['sale'] = ($val === '' ? '' : (is_numeric($val) ? (string)$val : ''));\n }\n }\n }\n\n if ($result['regular'] === null && $result['sale'] === null) {\n return new WP_Error('wc_fpa_api_no_price', 'No price returned from API');\n }\n\n return $result;\n}\n\n\n public function save_suppliers() {\n if (!current_user_can(self::CAP)) wp_die('Forbidden');\n check_admin_referer('wc_fpa_save_suppliers');\n\n $json = (string)($_POST['suppliers_json'] ?? '');\n $json = trim($json);\n\n // Validate JSON if provided.\n if ($json !== '') {\n $parsed = json_decode($json, true);\n if (!is_array($parsed)) {\n wp_safe_redirect(admin_url('admin.php?page='.self::PAGE.'&suppliers_saved=0'));\n exit;\n }\n }\n\n update_option(self::OPT_SUPPLIERS_JSON, $json);\n wp_safe_redirect(admin_url('admin.php?page='.self::PAGE.'&suppliers_saved=1'));\n exit;\n }\n\n public function menu() {\n add_submenu_page(\n 'woocommerce',\n 'Feeds Price Adjustment',\n 'Feeds Price Adjustment',\n self::CAP,\n self::PAGE,\n [$this,'page']\n );\n }\n\n public function page() {\n if (!current_user_can(self::CAP)) return;\n\n $cats = get_terms([\n 'taxonomy'=>'product_cat',\n 'hide_empty'=>false,\n 'orderby'=>'name'\n ]);\n ?>\n
\n

WooCommerce Feeds Price Adjustment

\n\n \n\n \n\n \n\n

Auto markup (no double-increase)

\n

Selling prices are calculated only from trusted supplier-cost snapshots. Ordinary WooCommerce/admin retail-price changes can never become supplier cost.

\n\n