repository = new Repository(); $this->diff = new DiffEngine(); $this->publisher = new WooPublisher(); } /** @return array{product_id:int,media_queued:int} */ public function publishChange(int $id, ?int $jobId = null): array { try { $change = $this->repository->change($id); if (!in_array($change['status'], ['approved', 'auto_approved', 'publishing'], true)) { throw new RuntimeException('Change is not approved.'); } $record = $this->repository->record((int) $change['record_id']); $run = $this->repository->run((int) $change['run_id']); $source = $this->repository->source((int) $run['source_id']); $profile = $this->repository->profile((int) $source['profile_id']); $canonical = $record['canonical']; $ownership = is_array($canonical['_ownership'] ?? null) ? $canonical['_ownership'] : $profile['ownership']; unset($canonical['_ownership']); $recovering = $change['status'] === 'publishing'; if (!$recovering) { $this->repository->updateChange($id, ['status' => 'publishing']); } $adapter = MultilingualAdapterFactory::fromCanonical($canonical); if (($canonical['type'] ?? '') === 'variation') { $parent = $this->repository->link((int) $source['id'], (string) $canonical['parent_external_id']); $canonical['_parent_product_id'] = MultilingualAdapterFactory::publicationProductId( $adapter, (int) ($parent['product_id'] ?? 0), $canonical ); } if (!empty($canonical['translations']) && !$adapter->canPublishProducts()) { throw new RuntimeException( $adapter->name() . ' product translations require the WooCommerce multilingual add-on.' ); } $images = array_values(array_filter(array_map('strval', (array) ($canonical['images'] ?? [])))); $imageChanged = $change['action'] === 'create' || array_key_exists('images', (array) ($change['diff']['fields'] ?? [])); $shouldQueueImages = $jobId !== null && $images && $imageChanged && (empty($change['product_id']) || ($ownership['images'] ?? 'source') !== 'merchant'); unset($canonical['images']); if ( $imageChanged && !$images && (empty($change['product_id']) || ($ownership['images'] ?? 'source') !== 'merchant') ) { $canonical['image_ids'] = []; } $productId = $change['product_id'] ? (int) $change['product_id'] : null; if ($recovering && !$productId) { $link = $this->repository->link((int) $source['id'], (string) $record['external_id']); $productId = !empty($link['product_id']) ? (int) $link['product_id'] : null; } if (!$productId && !empty($canonical['translations'])) { $recovered = $this->publisher->recoverProductId($canonical); $productId = $recovered > 0 ? $recovered : null; } if ($recovering && !$productId && !empty($canonical['sku'])) { $recovered = $adapter->productIdBySku((string) $canonical['sku']); $productId = $recovered > 0 ? $recovered : null; } if ($productId) { $productId = MultilingualAdapterFactory::publicationProductId( $adapter, $productId, $canonical ) ?: null; } $this->publisher->preflight($record['canonical'], $productId, $ownership); if ($change['action'] === 'update') { $changedFields = array_keys((array) ($change['diff']['fields'] ?? [])); $publisherFields = array_fill_keys( array_merge($changedFields, [ 'type', '_parent_product_id', 'external_id', 'parent_external_id', 'language', 'multilingual_plugin', '_crocoai_dbridge_client_key', '_wpi_client_key', 'translations', '_family', '_structural_migration', 'variant_external_id', '_attribute_ownership', '_destination_product_id', // Retain generic SEO precedence if a raw metadata change // is published alongside an unchanged generic SEO field. 'meta_title', 'meta_description', ]), true ); if (in_array('images', $changedFields, true)) { $publisherFields['image_ids'] = true; } $canonical = array_intersect_key($canonical, $publisherFields); $currentProduct = $productId ? wc_get_product($productId) : null; if ( $currentProduct && !$imageChanged && !array_key_exists('attributes', $canonical) && $this->diff->compare($canonical, $currentProduct, $ownership)['action'] === 'unchanged' ) { $this->publisher->ensureTranslations( (int) $currentProduct->get_id(), $record['canonical'] ); $this->publisher->synchronizeTranslations( (int) $currentProduct->get_id(), $record['canonical'], $ownership ); $hash = hash('sha256', wp_json_encode($record['canonical'])); $this->repository->touchLink( (int) $source['id'], (string) $record['external_id'], (int) $currentProduct->get_id(), $hash, (int) $run['id'], $record['canonical'] ); $variantExternalId = (string) ($record['canonical']['variant_external_id'] ?? ''); if (($record['canonical']['type'] ?? '') === 'simple' && $variantExternalId !== '') { $this->repository->touchLink( (int) $source['id'], $variantExternalId, (int) $currentProduct->get_id(), $hash, (int) $run['id'], $record['canonical'] ); } $sourceVariationExternalId = (string) ( $record['canonical']['_structural_migration']['source_variation_external_id'] ?? '' ); if (($record['canonical']['type'] ?? '') === 'simple' && $sourceVariationExternalId !== '') { $this->repository->touchLink( (int) $source['id'], $sourceVariationExternalId, (int) $currentProduct->get_id(), $hash, (int) $run['id'], $record['canonical'] ); } $this->repository->updateChange($id, [ 'product_id' => (int) $currentProduct->get_id(), 'status' => 'published', 'previous_data' => [], 'published_at' => current_time('mysql', true), ]); return ['product_id' => (int) $currentProduct->get_id(), 'media_queued' => 0]; } } $structuralPrevious = []; if ( $productId && is_array($canonical['_structural_migration'] ?? null) && ($canonical['_structural_migration']['confirmed'] ?? false) === true ) { $structuralPrevious = $this->publisher->beforeState($productId); $this->repository->updateChange($id, ['previous_data' => $structuralPrevious]); } $result = $this->publisher->publish($canonical, $productId, $ownership); $productId = (int) $result['product_id']; // A later translation or media-stage failure must still expose the // product already written so status, retries, and store state agree. $this->repository->updateChange($id, [ 'product_id' => $productId, 'previous_data' => $result['previous'], ]); $this->publisher->synchronizeTranslations( $productId, $record['canonical'], $ownership ); $hash = hash('sha256', wp_json_encode($record['canonical'])); $this->repository->touchLink( (int) $source['id'], (string) $record['external_id'], $result['product_id'], $hash, (int) $run['id'], $record['canonical'] ); $variantExternalId = (string) ($record['canonical']['variant_external_id'] ?? ''); if (($record['canonical']['type'] ?? '') === 'simple' && $variantExternalId !== '') { $this->repository->touchLink( (int) $source['id'], $variantExternalId, $result['product_id'], $hash, (int) $run['id'], $record['canonical'] ); } $sourceVariationExternalId = (string) ( $record['canonical']['_structural_migration']['source_variation_external_id'] ?? '' ); if (($record['canonical']['type'] ?? '') === 'simple' && $sourceVariationExternalId !== '') { $this->repository->touchLink( (int) $source['id'], $sourceVariationExternalId, $result['product_id'], $hash, (int) $run['id'], $record['canonical'] ); } $mediaQueued = $shouldQueueImages ? $this->repository->enqueueMedia($jobId, $id, $result['product_id'], $images) : 0; $this->repository->updateChange($id, [ 'product_id' => $result['product_id'], 'status' => 'published', 'previous_data' => $result['previous'], 'published_at' => current_time('mysql', true), ]); return ['product_id' => (int) $result['product_id'], 'media_queued' => $mediaQueued]; } catch (Throwable $error) { if (!empty($productId) && !empty($structuralPrevious)) { try { $this->publisher->restore((int) $productId, $structuralPrevious); } catch (Throwable $_restoreError) { // Preserve the publication error; family rollback below may // still restore the same record and related members. } } if (isset($run, $record) && is_array($record['canonical'] ?? null)) { $familyId = (string) ($record['canonical']['_family']['product_external_id'] ?? ''); if ($familyId !== '') { try { $this->rollbackPublishedFamily((int) $run['id'], $familyId); } catch (Throwable $_rollbackError) { // Keep the original publication error. The failed // change still records that manual recovery is needed. } } } $failed = [ 'status' => 'failed', 'error' => $this->redact($error->getMessage()), ]; if (!empty($productId) && wc_get_product((int) $productId)) { $failed['product_id'] = (int) $productId; } $this->repository->updateChange($id, $failed); throw $error; } } /** @return array */ public function diagnostics(): array { global $wpdb; $multilingual = MultilingualAdapterFactory::make(); $multilingualName = strtolower($multilingual->name()); return [ 'plugin_version' => CROCOAI_DBRIDGE_VERSION, 'bridge_protocol' => self::BRIDGE_PROTOCOL, 'selected_release_protocol' => self::SELECTED_RELEASE_PROTOCOL, 'multilingual_plugin' => in_array($multilingualName, ['wpml', 'polylang'], true) ? $multilingualName : 'none', 'authentication' => 'api_key', 'database_version' => get_option('crocoai_dbridge_db_version'), 'wordpress_version' => get_bloginfo('version'), 'woocommerce_version' => defined('WC_VERSION') ? WC_VERSION : null, 'cost_of_goods_sold' => $this->costOfGoodsSoldEnabled(), 'php_version' => PHP_VERSION, 'action_scheduler' => function_exists('as_enqueue_async_action'), 'database_tables' => array_reduce( ['sources', 'profiles', 'runs', 'records', 'links', 'changes', 'aliases', 'jobs', 'media_queue', 'remote_releases', 'attribute_ops', 'media_assets'], static function (array $carry, string $name) use ($wpdb): array { $table = Database::table($name); $carry[$name] = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table; return $carry; }, [] ), 'multilingual' => $multilingual->diagnostics(), 'worker_lock' => WorkerLock::diagnostics(), 'publication_jobs' => $this->repository->publicationDiagnostics(), 'active_jobs' => array_map(static fn (array $job): array => [ 'id' => (int) $job['id'], 'run_id' => $job['run_id'] === null ? null : (int) $job['run_id'], 'status' => (string) $job['status'], 'stage' => (string) $job['stage'], ], $this->repository->publicationBlockers()), 'attribute_lookup' => (array) get_option('crocoai_dbridge_attribute_lookup_rebuild', [ 'status' => get_option('woocommerce_attribute_lookup_enabled') === 'yes' ? 'ready' : 'disabled', ]), ]; } public function rollbackStructuralFamilyForChange(int $changeId): bool { $change = $this->repository->change($changeId); $record = $this->repository->record((int) $change['record_id']); $familyId = (string) ($record['canonical']['_family']['product_external_id'] ?? ''); if ($familyId === '') { return false; } global $wpdb; $rows = $wpdb->get_results($wpdb->prepare( 'SELECT r.canonical FROM %i c JOIN %i r ON r.id=c.record_id WHERE c.run_id=%d', Database::table('changes'), Database::table('records'), (int) $change['run_id'] ), ARRAY_A) ?: []; $structural = false; foreach ($rows as $row) { $canonical = json_decode((string) $row['canonical'], true) ?: []; if ( (string) ($canonical['_family']['product_external_id'] ?? '') === $familyId && ($canonical['_structural_migration']['confirmed'] ?? false) === true ) { $structural = true; break; } } if (!$structural) { return false; } $this->rollbackPublishedFamily((int) $change['run_id'], $familyId); return true; } private function costOfGoodsSoldEnabled(): bool { $controllerClass = \Automattic\WooCommerce\Internal\Features\FeaturesController::class; if (!function_exists('wc_get_container') || !class_exists($controllerClass)) { return false; } $controller = wc_get_container()->get($controllerClass); return is_callable([$controller, 'feature_is_enabled']) && $controller->feature_is_enabled('cost_of_goods_sold'); } private function redact(string $message): string { return preg_replace('/(?get_results($wpdb->prepare( 'SELECT c.id,c.product_id,c.action,c.previous_data,r.source_id,r.external_id,r.canonical ' . 'FROM %i c JOIN %i r ON r.id=c.record_id ' . "WHERE c.run_id=%d AND c.status IN ('published','publishing') ORDER BY c.id DESC", Database::table('changes'), Database::table('records'), $runId ), ARRAY_A) ?: []; foreach ($rows as $row) { $canonical = json_decode((string) $row['canonical'], true) ?: []; if ((string) ($canonical['_family']['product_external_id'] ?? '') !== $familyId) { continue; } $previous = json_decode((string) ($row['previous_data'] ?? ''), true) ?: []; if ((int) $row['product_id'] > 0 && $previous !== []) { $this->publisher->restore((int) $row['product_id'], $previous); $priorVariant = (string) ($canonical['_structural_migration']['previous_variant_external_id'] ?? ''); if ($priorVariant !== '') { $this->repository->touchLink( (int) $row['source_id'], $priorVariant, (int) $row['product_id'], hash('sha256', wp_json_encode($canonical)), $runId, $canonical ); } $survivorVariant = (string) ($canonical['_structural_migration']['active_survivor_external_id'] ?? ''); $survivorId = (int) ($canonical['_structural_migration']['active_survivor_id'] ?? 0); if ($survivorVariant !== '' && $survivorId > 0) { $this->repository->touchLink( (int) $row['source_id'], $survivorVariant, $survivorId, hash('sha256', wp_json_encode($canonical)), $runId, $canonical ); } } elseif ((int) $row['product_id'] > 0 && (string) $row['action'] === 'create') { wp_delete_post((int) $row['product_id'], true); $this->repository->deleteLink( (int) $row['source_id'], (string) $row['external_id'] ); $variantExternalId = (string) ($canonical['variant_external_id'] ?? ''); if ($variantExternalId !== '') { $this->repository->deleteLink( (int) $row['source_id'], $variantExternalId ); } } $this->repository->updateChange((int) $row['id'], [ 'status' => 'failed', 'error' => 'Family publication was rolled back after another member failed.', ]); } } }