movements = $movements; $this->settings = $settings; $this->resolver = $resolver; } /** * Set counted quantities for a product and/or its variations. * * @param int $product_id Parent or simple product ID. * @param array $counted Stock-holding product ID => counted quantity. * @param string $reason One of REASONS. * @param string $note Optional note. * @return array{changed:array>,unchanged:int}|WP_Error */ public function adjust( int $product_id, array $counted, string $reason, string $note = '' ) { $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 ( ! in_array( $reason, self::REASONS, true ) ) { return new WP_Error( 'profit_engine_adjust_reason', __( 'Choose a reason for the adjustment.', 'profit-engine' ), array( 'status' => 400 ) ); } if ( ! $counted ) { return new WP_Error( 'profit_engine_adjust_empty', __( 'Enter at least one counted quantity.', 'profit-engine' ), array( 'status' => 400 ) ); } // Only this product or its own variations, and only ones that track stock. $allowed = array_merge( array( $product_id ), array_map( 'intval', $product->get_children() ) ); $targets = array(); foreach ( $counted as $id => $qty ) { $id = (int) $id; if ( ! in_array( $id, $allowed, true ) ) { return new WP_Error( 'profit_engine_adjust_foreign', __( 'That variation does not belong to this product.', 'profit-engine' ), array( 'status' => 400 ) ); } $target = $this->resolver->product( $id ); if ( ! $target || true !== $target->get_manage_stock() ) { return new WP_Error( 'profit_engine_adjust_untracked', __( 'Stock tracking is off for this item. Turn it on first.', 'profit-engine' ), array( 'status' => 409 ) ); } if ( ! is_numeric( $qty ) || (float) $qty < 0 ) { return new WP_Error( 'profit_engine_adjust_qty', __( 'Counted quantities must be 0 or more.', 'profit-engine' ), array( 'status' => 400 ) ); } $targets[ $id ] = array( $target, (float) $qty ); } $changed = array(); $unchanged = 0; foreach ( $targets as $id => list( $target, $qty ) ) { $before = (float) ( $target->get_stock_quantity() ?? 0 ); $delta = $qty - $before; if ( abs( $delta ) < 0.000001 ) { ++$unchanged; continue; } wc_update_product_stock( $target, $qty, 'set' ); $cost = $this->settings->get( $id ); $this->movements->record( array( 'product_id' => $id, 'type' => 'adjust_' . $reason, 'qty' => $delta, 'unit_cost' => $cost ? Money::to_float( $cost['current_cost'] ?? 0 ) : 0, 'reference_type' => 'user', 'reference_id' => get_current_user_id(), 'note' => mb_substr( $note, 0, 250 ), ) ); $changed[ $id ] = array( 'before' => $before, 'after' => $qty, ); } if ( $changed && $product->is_type( 'variable' ) && class_exists( '\WC_Product_Variable' ) ) { \WC_Product_Variable::sync( $product_id, true ); } if ( $changed ) { /** * Fires after stock is adjusted by hand. * * @param int $product_id Parent or simple product ID. * @param array> $changed ID => { before, after }. * @param string $reason Reason. */ do_action( 'profit_engine_stock_adjusted', $product_id, $changed, $reason ); } return array( 'changed' => $changed, 'unchanged' => $unchanged, ); } /** * Switch a variable product between one shared (product-level) stock and * separate stock per variation, carrying the quantities across. * * - "shared": every variation stops tracking its own stock and the product * tracks $shared_qty for all of them. * - "variation": the product stops tracking a shared quantity and each * variation tracks its own quantity from $per_variation (0 if missing). * * Each quantity change is logged as a "stock_mode" movement. * * @param int $product_id Variable product ID. * @param string $mode "shared" or "variation". * @param float|null $shared_qty Quantity for "shared". * @param array $per_variation Variation ID => quantity for "variation". * @return array|WP_Error { mode, stock } */ public function set_mode( int $product_id, string $mode, ?float $shared_qty, array $per_variation ) { $product = $this->resolver->product( $product_id ); if ( ! $product || ! $product->is_type( 'variable' ) ) { return new WP_Error( 'profit_engine_stock_mode_type', __( 'Only variable products can switch between shared and per-variation stock.', 'profit-engine' ), array( 'status' => 400 ) ); } if ( ! in_array( $mode, array( 'shared', 'variation' ), true ) ) { return new WP_Error( 'profit_engine_stock_mode', __( 'Unknown stock mode.', 'profit-engine' ), array( 'status' => 400 ) ); } $children = array_map( 'intval', $product->get_children() ); $log = function ( int $id, float $before, float $after, string $note ): void { if ( abs( $after - $before ) < 0.000001 ) { return; } $cost = $this->settings->get( $id ); $this->movements->record( array( 'product_id' => $id, 'type' => 'stock_mode', 'qty' => $after - $before, 'unit_cost' => $cost ? Money::to_float( $cost['current_cost'] ?? 0 ) : 0, 'reference_type' => 'user', 'reference_id' => get_current_user_id(), 'note' => $note, ) ); }; if ( 'shared' === $mode ) { $qty = max( 0.0, (float) $shared_qty ); foreach ( $children as $id ) { $variation = $this->resolver->product( $id ); if ( $variation && true === $variation->get_manage_stock() ) { $log( $id, (float) ( $variation->get_stock_quantity() ?? 0 ), 0.0, __( 'Moved to shared stock', 'profit-engine' ) ); $variation->set_manage_stock( false ); $variation->save(); } } $before = true === $product->get_manage_stock() ? (float) ( $product->get_stock_quantity() ?? 0 ) : 0.0; $product->set_manage_stock( true ); $product->save(); $product = $this->resolver->product( $product_id ); wc_update_product_stock( $product, $qty, 'set' ); $log( $product_id, $before, $qty, __( 'Switched to shared stock', 'profit-engine' ) ); } else { if ( true === $product->get_manage_stock() ) { $log( $product_id, (float) ( $product->get_stock_quantity() ?? 0 ), 0.0, __( 'Switched to per-variation stock', 'profit-engine' ) ); $product->set_manage_stock( false ); $product->save(); } foreach ( $children as $id ) { $variation = $this->resolver->product( $id ); if ( ! $variation ) { continue; } $before = true === $variation->get_manage_stock() ? (float) ( $variation->get_stock_quantity() ?? 0 ) : 0.0; $qty = max( 0.0, (float) ( $per_variation[ $id ] ?? 0 ) ); $variation->set_manage_stock( true ); $variation->set_stock_quantity( $qty ); $variation->set_stock_status( $qty > 0 ? 'instock' : 'outofstock' ); $variation->save(); $log( $id, $before, $qty, __( 'Switched to per-variation stock', 'profit-engine' ) ); } } if ( class_exists( '\WC_Product_Variable' ) ) { \WC_Product_Variable::sync( $product_id, true ); } return array( 'mode' => $mode, 'stock' => $this->resolver->total_stock( $product_id ), ); } /** * Recent stock movements for a product and its variations. * * @param int $product_id Parent or simple product ID. * @param int $limit Max rows. * @return array> */ public function history( int $product_id, int $limit = 10 ): array { $product = $this->resolver->product( $product_id ); if ( ! $product ) { return array(); } $ids = array_merge( array( $product_id ), array_map( 'intval', $product->get_children() ) ); $rows = $this->movements->recent_for_products( $ids, $limit ); $names = array(); $out = array(); foreach ( $rows as $row ) { $user_id = 'user' === $row['reference_type'] ? (int) $row['reference_id'] : 0; if ( $user_id && ! isset( $names[ $user_id ] ) ) { $user = get_userdata( $user_id ); $names[ $user_id ] = $user ? $user->display_name : ''; } $out[] = array( 'id' => (int) $row['id'], 'product_id' => (int) $row['product_id'], 'label' => $this->resolver->unit_label( (int) $row['product_id'] ), 'type' => (string) $row['type'], 'qty' => Money::to_float( $row['qty'] ), 'reference' => 'po' === $row['reference_type'] ? (int) $row['reference_id'] : 0, 'user' => $user_id ? $names[ $user_id ] : '', 'note' => (string) $row['note'], 'time' => (int) strtotime( (string) $row['created_at'] . ' UTC' ), ); } return $out; } }