products = $products; $this->taxonomy = $taxonomy; } /** * Preview matching products without mutating them. * * @param array $filters Filter inputs. * @param array $changes Change inputs. */ public function preview( array $filters, array $changes ): BulkUpdateProductsResult { return $this->run( $filters, $changes, false ); } /** * Apply changes after the caller has shown a preview. * * @param array $filters Filter inputs. * @param array $changes Change inputs. */ public function apply( array $filters, array $changes ): BulkUpdateProductsResult { return $this->run( $filters, $changes, true ); } /** * Run preview or mutation. * * @param array $filters Filter inputs. * @param array $changes Change inputs. * @param bool $mutate Whether to apply changes. * * @throws InvalidArgumentException When the change set is empty or invalid. */ private function run( array $filters, array $changes, bool $mutate ): BulkUpdateProductsResult { $normalizedFilters = self::normalizeFilters( $filters ); $normalizedChanges = self::normalizeChanges( $changes ); if ( $normalizedChanges === array() ) { throw new InvalidArgumentException( 'At least one bulk product change is required.' ); } $matched = 0; $updated = 0; $sample = array(); $page = 1; do { $result = $this->products->findPaginated( new ProductListQuery( $page, 50, $normalizedFilters['provider'], null, null, null, $normalizedFilters['lifecycle_state'], ProductListQuery::SORT_KEY_UPDATED_AT, ProductListQuery::SORT_ORDER_ASC ) ); foreach ( $result->items() as $record ) { if ( ! $this->matchesCategoryFilter( $record, $normalizedFilters['category'] ) ) { continue; } ++$matched; if ( count( $sample ) < self::MAX_PREVIEW_RECORDS ) { $sample[] = $record; } if ( $mutate && $this->applyChanges( $record, $normalizedChanges ) ) { ++$updated; } } ++$page; } while ( $page <= $result->totalPages() ); return new BulkUpdateProductsResult( $matched, $updated, $sample ); } /** * Check category filter. * * @param ProductRecord $record Product record. * @param string|null $category Category filter. */ private function matchesCategoryFilter( ProductRecord $record, ?string $category ): bool { if ( $category === null ) { return true; } $id = $record->id(); return $id !== null && $this->taxonomy->productHasCategory( $id, $category ); } /** * Apply normalized changes to one record. * * @param ProductRecord $record Product record. * @param array $changes Normalized changes. */ private function applyChanges( ProductRecord $record, array $changes ): bool { $id = $record->id(); if ( $id === null ) { return false; } if ( array_key_exists( 'lifecycle_state', $changes ) && is_string( $changes['lifecycle_state'] ) ) { $lifecycleState = $changes['lifecycle_state']; $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); $row = $record->toArray(); $row['lifecycle_state'] = $lifecycleState; $row['archived_at'] = $lifecycleState === ProductListQuery::LIFECYCLE_STATE_ACTIVE ? null : $now; $row['updated_at'] = $now; $this->products->save( ProductRecord::fromArray( $row ) ); } if ( array_key_exists( 'categories', $changes ) ) { $this->taxonomy->saveCategoriesForProduct( $id, ProductManualTaxonomy::termsFromInput( $changes['categories'] ) ); } if ( array_key_exists( 'tags', $changes ) ) { $this->taxonomy->saveTagsForProduct( $id, ProductManualTaxonomy::termsFromInput( $changes['tags'] ) ); } return true; } /** * Normalize filters. * * @param array $filters Filter inputs. * * @return array{provider:?string,lifecycle_state:?string,category:?string} */ private static function normalizeFilters( array $filters ): array { return array( 'provider' => self::optionalString( $filters['provider'] ?? null ), 'lifecycle_state' => self::optionalLifecycleState( $filters['lifecycle_state'] ?? null ), 'category' => self::firstTermOrNull( $filters['category'] ?? null ), ); } /** * Normalize changes. * * @param array $changes Change inputs. * * @return array */ private static function normalizeChanges( array $changes ): array { $normalized = array(); $lifecycle = self::optionalLifecycleState( $changes['lifecycle_state'] ?? null ); if ( $lifecycle !== null ) { $normalized['lifecycle_state'] = $lifecycle; } if ( array_key_exists( 'categories', $changes ) ) { $normalized['categories'] = ProductManualTaxonomy::termsFromInput( $changes['categories'] ); } if ( array_key_exists( 'tags', $changes ) ) { $normalized['tags'] = ProductManualTaxonomy::termsFromInput( $changes['tags'] ); } return $normalized; } /** * Optional trimmed string. * * @param mixed $value Raw value. */ private static function optionalString( mixed $value ): ?string { if ( ! is_scalar( $value ) ) { return null; } $trimmed = trim( (string) $value ); return $trimmed === '' ? null : $trimmed; } /** * Optional lifecycle state. * * @param mixed $value Raw value. * * @throws InvalidArgumentException When lifecycle state is invalid. */ private static function optionalLifecycleState( mixed $value ): ?string { $state = self::optionalString( $value ); if ( $state === null ) { return null; } $valid = array( ProductListQuery::LIFECYCLE_STATE_ACTIVE, ProductListQuery::LIFECYCLE_STATE_ARCHIVED, ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING, ); if ( ! in_array( $state, $valid, true ) ) { throw new InvalidArgumentException( 'Invalid lifecycle state.' ); } return $state; } /** * First normalized term or null. * * @param mixed $value Raw value. */ private static function firstTermOrNull( mixed $value ): ?string { $terms = ProductManualTaxonomy::termsFromInput( $value ); return $terms[0] ?? null; } }