supplier_products = $supplier_products; $this->settings = $settings; $this->resolver = $resolver; } /** * Paginated list of parent products with a purchasing summary. * * @param array $args Query args (search, page, per_page, product_type). * @return array{items:array>,total:int,pages:int} */ public function list_products( array $args ): array { if ( ! function_exists( 'wc_get_products' ) ) { return array( 'items' => array(), 'total' => 0, 'pages' => 0, ); } $page = max( 1, (int) ( $args['page'] ?? 1 ) ); $per_page = min( 100, max( 1, (int) ( $args['per_page'] ?? 20 ) ) ); $query_args = array( 'status' => array( 'publish', 'private' ), 'limit' => $per_page, 'page' => $page, 'paginate' => true, 'orderby' => 'title', 'order' => 'ASC', 'type' => array( 'simple', 'variable' ), ); $search = isset( $args['search'] ) ? trim( (string) $args['search'] ) : ''; if ( '' !== $search ) { $query_args['s'] = $search; } $results = wc_get_products( $query_args ); // Preload purchasing settings + primary suppliers for every unit on the // page in two queries, instead of two queries per product. $unit_ids = array(); foreach ( $results->products as $product ) { foreach ( $this->resolver->purchasable_units( (int) $product->get_id() ) as $unit_id ) { $unit_ids[] = (int) $unit_id; } } $settings_map = $this->settings->get_many( $unit_ids ); $primary_map = $this->supplier_products->primary_for_products( $unit_ids ); $items = array(); foreach ( $results->products as $product ) { $items[] = $this->summarize_product( (int) $product->get_id(), $settings_map, $primary_map ); } return array( 'items' => $items, 'total' => (int) $results->total, 'pages' => (int) $results->max_num_pages, ); } /** * Search purchasable units (simple products + variations) for pickers. * * Returns a flat list so a purchase-order line can reference the exact unit, * pre-filled with a suggested cost (primary supplier cost, else current cost). * * @param string $term Search term. * @param int $limit Max parent products to scan. * @return array> */ public function search_units( string $term, int $limit = 20 ): array { if ( ! function_exists( 'wc_get_products' ) ) { return array(); } $products = wc_get_products( array( 'status' => array( 'publish', 'private' ), 'limit' => $limit, 's' => $term, 'type' => array( 'simple', 'variable' ), 'return' => 'objects', ) ); // Flatten to the list of purchasable units first, then resolve supplier // links and settings in TWO batch queries (avoids an N+1 per unit). $unit_ids = array(); foreach ( $products as $product ) { foreach ( $this->resolver->purchasable_units( (int) $product->get_id() ) as $unit_id ) { $unit_ids[] = (int) $unit_id; } } if ( empty( $unit_ids ) ) { return array(); } $primaries = $this->supplier_products->primary_for_products( $unit_ids ); $settings = $this->settings->get_many( $unit_ids ); $units = array(); foreach ( $unit_ids as $unit_id ) { $primary = $primaries[ $unit_id ] ?? null; $setting = $settings[ $unit_id ] ?? null; $cost = 0.0; if ( $primary ) { $cost = Money::to_float( $primary['purchase_cost'] ); } elseif ( $setting ) { $cost = Money::to_float( $setting['current_cost'] ); } $units[] = array( 'id' => $unit_id, 'label' => $this->resolver->unit_label( $unit_id ), 'sku' => $this->resolver->sku( $unit_id ), 'image' => $this->resolver->image_url( $unit_id ), 'supplier_sku' => $primary ? (string) $primary['supplier_sku'] : '', 'supplier_id' => $primary ? (int) $primary['supplier_id'] : 0, 'cost' => $cost, ); } return $units; } /** * Full purchasing detail for a product, including all purchasable units. * * @param int $product_id Parent (or simple) product ID. * @return array|WP_Error */ public function get_product( int $product_id ) { $product = $this->resolver->product( $product_id ); if ( ! $product ) { return new WP_Error( 'profit_engine_product_not_found', __( 'Product not found.', 'profit-engine' ), array( 'status' => 404 ) ); } $unit_ids = $this->resolver->purchasable_units( $product_id ); $settings = $this->settings->get_many( $unit_ids ); $units = array(); foreach ( $unit_ids as $unit_id ) { $units[] = $this->unit_detail( $unit_id, $settings[ $unit_id ] ?? null ); } return array( 'id' => (int) $product->get_id(), 'name' => $product->get_name(), 'sku' => (string) $product->get_sku(), 'type' => $product->get_type(), 'variation_count' => count( $unit_ids ), 'stock' => $this->resolver->total_stock( $product_id ), 'parent_managed_stock' => $this->resolver->manages_stock_at_parent( $product_id ), 'units' => $units, ); } /** * Get a single purchasable unit's detail. * * @param int $unit_id Purchasable-unit ID. * @return array|WP_Error */ public function get_unit( int $unit_id ) { if ( ! $this->resolver->product( $unit_id ) ) { return new WP_Error( 'profit_engine_product_not_found', __( 'Product not found.', 'profit-engine' ), array( 'status' => 404 ) ); } return $this->unit_detail( $unit_id, $this->settings->get( $unit_id ) ); } /** * Save purchasing data for a purchasable unit. * * @param int $unit_id Purchasable-unit ID. * @param array $input Raw payload. * @return array|WP_Error */ public function update_unit( int $unit_id, array $input ) { if ( ! $this->resolver->product( $unit_id ) ) { return new WP_Error( 'profit_engine_product_not_found', __( 'Product not found.', 'profit-engine' ), array( 'status' => 404 ) ); } $existing = $this->settings->get( $unit_id ); // Build settings row. $settings_data = array( 'safety_stock' => isset( $input['safety_stock'] ) ? max( 0, (int) $input['safety_stock'] ) : (int) ( $existing['safety_stock'] ?? 0 ), 'reorder_point' => $this->nullable_int( $input['reorder_point'] ?? ( $existing['reorder_point'] ?? null ) ), 'manual_reorder_qty' => $this->nullable_int( $input['manual_reorder_qty'] ?? ( $existing['manual_reorder_qty'] ?? null ) ), 'forecast_window_days' => $this->nullable_int( $input['forecast_window_days'] ?? ( $existing['forecast_window_days'] ?? null ) ), 'enabled' => isset( $input['enabled'] ) ? (int) (bool) $input['enabled'] : (int) ( $existing['enabled'] ?? 1 ), ); // Optional supplier link. $supplier = isset( $input['supplier'] ) && is_array( $input['supplier'] ) ? $input['supplier'] : array(); $supplier_id = isset( $supplier['supplier_id'] ) ? (int) $supplier['supplier_id'] : 0; $purchase_cost = array_key_exists( 'purchase_cost', $supplier ) ? Money::store( $supplier['purchase_cost'] ) : null; if ( $supplier_id > 0 ) { $this->supplier_products->upsert( $supplier_id, $unit_id, array( 'supplier_sku' => isset( $supplier['supplier_sku'] ) ? sanitize_text_field( (string) $supplier['supplier_sku'] ) : '', 'purchase_cost' => $purchase_cost ?? Money::store( 0 ), 'currency' => $this->supplier_currency( $supplier_id ), 'moq' => isset( $supplier['moq'] ) ? max( 0, (int) $supplier['moq'] ) : 0, 'lead_time_days' => isset( $supplier['lead_time_days'] ) ? max( 0, (int) $supplier['lead_time_days'] ) : 0, 'is_primary' => 1, ) ); $this->supplier_products->clear_primary_except( $unit_id, $supplier_id ); } // Determine current cost. // Explicit manual override wins; otherwise seed from the primary supplier // cost the first time we have one. Ongoing maintenance of current/last/avg // happens on PO receipt (Phase 2/3). $current_cost = null; if ( array_key_exists( 'current_cost', $input ) && '' !== $input['current_cost'] && null !== $input['current_cost'] ) { $current_cost = Money::store( $input['current_cost'] ); } elseif ( null !== $purchase_cost && empty( $existing['current_cost'] ) ) { $current_cost = $purchase_cost; } if ( null !== $current_cost ) { $settings_data['current_cost'] = $current_cost; if ( empty( $existing['avg_purchase_cost'] ) ) { $settings_data['avg_purchase_cost'] = $current_cost; } } $this->settings->upsert( $unit_id, $settings_data ); return $this->get_unit( $unit_id ); } /** * Enable WooCommerce stock management on a product (and optionally seed a * starting quantity), so inventory/reorder/averaging can work for it. * * Variable products get per-variation stock management enabled. Each * variation takes its own quantity from $per_unit; $qty is only a fallback * for variations not listed there (and the quantity for simple products). * * @param int $product_id Product ID. * @param float|null $qty Optional starting quantity. * @param array $per_unit Variation ID => starting quantity. * @return array|WP_Error */ public function enable_stock_tracking( int $product_id, ?float $qty, array $per_unit = array() ) { $product = $this->resolver->product( $product_id ); if ( ! $product ) { return new WP_Error( 'profit_engine_product_not_found', __( 'Product not found.', 'profit-engine' ), array( 'status' => 404 ) ); } if ( $product->is_type( 'variable' ) ) { // With only per-variation quantities, just those variations are switched on // (e.g. re-enabling one variation leaves the others as they are). $only_listed = null === $qty && ! empty( $per_unit ); foreach ( $product->get_children() as $child_id ) { if ( $only_listed && ! array_key_exists( (int) $child_id, $per_unit ) ) { continue; } $variation = $this->resolver->product( (int) $child_id ); if ( $variation ) { $variation_qty = $per_unit[ (int) $child_id ] ?? $qty; $variation->set_manage_stock( true ); if ( null !== $variation_qty ) { $variation->set_stock_quantity( $variation_qty ); $variation->set_stock_status( $variation_qty > 0 ? 'instock' : 'outofstock' ); } $variation->save(); } } // Recompute the parent's aggregate stock status/price from its // variations, otherwise the parent (and the WooCommerce product list) // keeps showing a stale status even though variations now track stock. if ( class_exists( '\WC_Product_Variable' ) ) { \WC_Product_Variable::sync( $product_id, true ); } } else { $product->set_manage_stock( true ); if ( null !== $qty ) { $product->set_stock_quantity( $qty ); } $product->save(); } return $this->summarize_product( $product_id ); } /** * Turn WooCommerce stock management off for a product (every variation of a * variable product, and the parent if it managed stock itself). * * WooCommerce clears the stored quantity when stock management is off. * Sales, profit, purchase orders and stock-movement history are not touched. * * @param int $product_id Product ID. * @return array|WP_Error */ public function disable_stock_tracking( int $product_id ) { $product = $this->resolver->product( $product_id ); if ( ! $product ) { return new WP_Error( 'profit_engine_product_not_found', __( 'Product not found.', 'profit-engine' ), array( 'status' => 404 ) ); } if ( $product->is_type( 'variable' ) ) { foreach ( $product->get_children() as $child_id ) { $variation = $this->resolver->product( (int) $child_id ); if ( $variation && true === $variation->get_manage_stock() ) { $variation->set_manage_stock( false ); $variation->save(); } } } if ( true === $product->get_manage_stock() ) { $product->set_manage_stock( false ); $product->save(); } if ( $product->is_type( 'variable' ) && class_exists( '\WC_Product_Variable' ) ) { \WC_Product_Variable::sync( $product_id, true ); } return $this->summarize_product( $product_id ); } /** * Bulk-assign a supplier (and optional cost) to many products at once. * * Each product is expanded to its purchasable units so variable products get * the supplier applied to every variation. * * @param int[] $product_ids Parent/simple product IDs. * @param int $supplier_id Supplier to assign. * @param float|null $cost Optional purchase cost to set on each link. * @return array{updated:int,units:int} */ public function bulk_assign_supplier( array $product_ids, int $supplier_id, ?float $cost ): array { $updated = 0; $units = 0; foreach ( array_unique( array_map( 'intval', $product_ids ) ) as $product_id ) { if ( $product_id <= 0 ) { continue; } $has_unit = false; foreach ( $this->resolver->purchasable_units( $product_id ) as $unit_id ) { $supplier = array( 'supplier_id' => $supplier_id ); if ( null !== $cost ) { $supplier['purchase_cost'] = $cost; } $result = $this->update_unit( $unit_id, array( 'supplier' => $supplier ) ); if ( ! is_wp_error( $result ) ) { ++$units; $has_unit = true; } } if ( $has_unit ) { ++$updated; } } return array( 'updated' => $updated, 'units' => $units, ); } /** * Build a parent-product list summary. * * @param int $product_id Parent product ID. * @param array>|null $settings_map Preloaded settings keyed by unit ID (null = load here). * @param array>|null $primary_map Preloaded primary suppliers keyed by unit ID (null = load here). * @return array */ private function summarize_product( int $product_id, ?array $settings_map = null, ?array $primary_map = null ): array { $product = $this->resolver->product( $product_id ); $units = $this->resolver->purchasable_units( $product_id ); // Single-product callers don't preload; fetch this product's units in one batch each. $settings_map = $settings_map ?? $this->settings->get_many( $units ); $primary_map = $primary_map ?? $this->supplier_products->primary_for_products( $units ); $is_variable = $product && $product->is_type( 'variable' ); $stock = $this->resolver->total_stock( $product_id ); // Product thumbnail (falls back to WooCommerce's placeholder image). $image = null; if ( $product ) { $image_id = (int) $product->get_image_id(); $image = $image_id > 0 ? wp_get_attachment_image_url( $image_id, 'thumbnail' ) : ''; if ( ! $image && function_exists( 'wc_placeholder_img_src' ) ) { $image = wc_placeholder_img_src( 'thumbnail' ); } } // Selling price + low-stock threshold from WooCommerce (variable products // show the cheapest variation's price as the representative figure). $regular_price = null; $sale_price = null; $price = null; $low_stock = null; if ( $product ) { if ( $is_variable ) { $min_reg = $product->get_variation_regular_price( 'min', true ); $min_act = $product->get_variation_price( 'min', true ); $regular_price = '' !== $min_reg ? (float) $min_reg : null; $price = '' !== $min_act ? (float) $min_act : null; } else { $regular_price = '' !== $product->get_regular_price() ? (float) $product->get_regular_price() : null; $sale_price = '' !== $product->get_sale_price() ? (float) $product->get_sale_price() : null; $price = '' !== $product->get_price() ? (float) $product->get_price() : $regular_price; $low_raw = $product->get_low_stock_amount(); $low_stock = '' !== $low_raw ? (int) $low_raw : null; } } $summary = array( 'id' => $product_id, 'image' => $image ? (string) $image : null, 'name' => $product ? $product->get_name() : ( '#' . $product_id ), 'sku' => $product ? (string) $product->get_sku() : '', 'type' => $product ? $product->get_type() : 'simple', 'variation_count' => count( $units ), 'stock' => $stock, 'regular_price' => $regular_price, 'sale_price' => $sale_price, 'price' => $price, 'low_stock_threshold' => $low_stock, 'primary_supplier_id' => null, 'primary_supplier_name' => '', 'current_cost' => null, 'gross_profit' => null, 'margin' => null, 'inventory_value' => null, 'stock_status' => null, 'reorder_point' => null, 'reorder_point_source' => 'auto', 'configured' => false, ); // For simple products, surface the single unit's purchasing summary inline. if ( ! $is_variable && ! empty( $units ) ) { $unit_id = $units[0]; $settings = $settings_map[ (int) $unit_id ] ?? null; $primary = $primary_map[ (int) $unit_id ] ?? null; if ( $primary ) { $summary['primary_supplier_id'] = (int) $primary['supplier_id']; $summary['primary_supplier_name'] = (string) $primary['supplier_name']; } if ( $settings ) { $summary['current_cost'] = Money::to_float( $settings['current_cost'] ); // A manual reorder point is a stored value; otherwise the forecast // derives one automatically from velocity + lead time + safety stock. $manual = null === $settings['reorder_point'] ? null : (int) $settings['reorder_point']; $summary['reorder_point'] = $manual; $summary['reorder_point_source'] = null === $manual ? 'auto' : 'manual'; $summary['configured'] = true; } } // For variable products, show the cost as a range across configured // variations (a single value when they're all equal). if ( $is_variable && ! empty( $units ) ) { $costs = array(); foreach ( $units as $unit_id ) { $settings = $settings_map[ (int) $unit_id ] ?? null; if ( $settings && Money::to_float( $settings['current_cost'] ) > 0 ) { $costs[] = Money::to_float( $settings['current_cost'] ); } } if ( ! empty( $costs ) ) { $summary['cost_min'] = min( $costs ); $summary['cost_max'] = max( $costs ); $summary['current_cost'] = min( $costs ); $summary['configured'] = true; } } // Derived stock/profit figures used by the Stock grid. $cost = $summary['current_cost']; if ( null !== $cost && null !== $price && $price > 0 ) { $summary['gross_profit'] = round( $price - $cost, 2 ); $summary['margin'] = ( $price - $cost ) / $price; } if ( null !== $stock && null !== $cost ) { $summary['inventory_value'] = round( $stock * $cost, 2 ); } $summary['stock_status'] = $this->stock_status( $stock, $low_stock ); return $summary; } /** * Classify a product's stock into a grid status. * * @param float|null $stock Current (total) stock, null when untracked. * @param int|null $threshold Low-stock threshold, if set. */ private function stock_status( ?float $stock, ?int $threshold ): string { if ( null === $stock ) { return 'untracked'; } if ( $stock <= 0 ) { return 'out'; } if ( null !== $threshold && $threshold > 0 && $stock <= $threshold ) { return 'low'; } return 'in'; } /** * Detailed purchasing view for a single unit. * * @param int $unit_id Purchasable-unit ID. * @param array|null $settings Settings row, if loaded. * @return array */ private function unit_detail( int $unit_id, ?array $settings ): array { $base = $this->resolver->unit_summary( $unit_id ) ?? array( 'id' => $unit_id ); $primary = $this->supplier_products->primary_for_product( $unit_id ); $links = $this->supplier_products->for_product( $unit_id ); $base['settings'] = array( 'safety_stock' => (int) ( $settings['safety_stock'] ?? 0 ), 'reorder_point' => isset( $settings['reorder_point'] ) && null !== $settings['reorder_point'] ? (int) $settings['reorder_point'] : null, 'manual_reorder_qty' => isset( $settings['manual_reorder_qty'] ) && null !== $settings['manual_reorder_qty'] ? (int) $settings['manual_reorder_qty'] : null, 'forecast_window_days' => isset( $settings['forecast_window_days'] ) && null !== $settings['forecast_window_days'] ? (int) $settings['forecast_window_days'] : null, 'current_cost' => Money::to_float( $settings['current_cost'] ?? 0 ), 'last_purchase_cost' => Money::to_float( $settings['last_purchase_cost'] ?? 0 ), 'avg_purchase_cost' => Money::to_float( $settings['avg_purchase_cost'] ?? 0 ), 'enabled' => (bool) ( $settings['enabled'] ?? 1 ), ); $base['primary_supplier'] = $primary ? array( 'supplier_id' => (int) $primary['supplier_id'], 'supplier_name' => (string) $primary['supplier_name'], 'purchase_cost' => Money::to_float( $primary['purchase_cost'] ), 'supplier_sku' => (string) $primary['supplier_sku'], 'moq' => (int) $primary['moq'], 'lead_time_days' => (int) $primary['lead_time_days'], 'currency' => (string) $primary['currency'], ) : null; $base['suppliers'] = array_map( static fn( array $l ): array => array( 'supplier_id' => (int) $l['supplier_id'], 'supplier_name' => (string) $l['supplier_name'], 'purchase_cost' => Money::to_float( $l['purchase_cost'] ), 'supplier_sku' => (string) $l['supplier_sku'], 'moq' => (int) $l['moq'], 'lead_time_days' => (int) $l['lead_time_days'], 'is_primary' => (bool) $l['is_primary'], ), $links ); return $base; } /** * The supplier's configured currency (falls back to store currency). * * @param int $supplier_id Supplier ID. */ private function supplier_currency( int $supplier_id ): string { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $currency = (string) $wpdb->get_var( $wpdb->prepare( "SELECT currency FROM {$wpdb->prefix}profit_engine_suppliers WHERE id = %d", $supplier_id ) ); if ( '' === $currency && function_exists( 'get_woocommerce_currency' ) ) { $currency = get_woocommerce_currency(); } return substr( $currency, 0, 3 ); } /** * Coerce a value to a nullable non-negative int. * * @param mixed $value Raw value. */ private function nullable_int( $value ): ?int { if ( null === $value || '' === $value ) { return null; } return max( 0, (int) $value ); } }