diff --git a/config/shopify.php b/config/shopify.php index 1a3e992..f994d1f 100644 --- a/config/shopify.php +++ b/config/shopify.php @@ -128,6 +128,13 @@ */ 'sales_channel' => env('SHOPIFY_SALES_CHANNEL', 'Online Store'), + /** + * When true (default), all products are imported regardless of sales channel membership. + * When false, only products assigned to the configured sales_channel are imported. + * Products previously imported that are no longer on the sales channel will be deleted. + */ + 'import_all_products' => true, + /** * Your App's Admin API Auth Key * Note: this is not required unless you are doing custom integrations using the RestApi diff --git a/docs/content/en/CMS/importing-data.md b/docs/content/en/CMS/importing-data.md index 04b85c7..aefa738 100644 --- a/docs/content/en/CMS/importing-data.md +++ b/docs/content/en/CMS/importing-data.md @@ -99,6 +99,16 @@ Alt text is imported from Shopify alongside each product and variant image. When By default the `published` state and `published_at` of the product is determined by the values of `Online Store` sales channel. If you want to use a different sales channel to determine availability you can specify the name of the channel in the `SHOPIFY_SALES_CHANNEL` env variable, e.g. `SHOPIFY_SALES_CHANNEL="My other channel"`. +## Sales channel filtering + +By default all Shopify products are imported regardless of whether they are assigned to the configured sales channel. To restrict imports to only products on the sales channel, set `import_all_products` to `false` in `config/shopify.php`: + +```php +'import_all_products' => false, +``` + +When this is enabled, any product that is not assigned to the configured sales channel will be skipped. If a product was previously imported and is later removed from the sales channel, it and all of its variants will be deleted from Statamic on the next import. + ## Metafields Any product and variant meta fields will be automatically added to the Statamic entry data, with the same handle as their key in Shopify and using the raw value. diff --git a/src/Jobs/ImportSingleProductJob.php b/src/Jobs/ImportSingleProductJob.php index a87f4a9..660af70 100644 --- a/src/Jobs/ImportSingleProductJob.php +++ b/src/Jobs/ImportSingleProductJob.php @@ -131,6 +131,20 @@ public function handle() ->where('product_id', $this->data['id']) ->first(); + if (! config('shopify.import_all_products', true)) { + $inSalesChannel = collect(Arr::get($this->data, 'resourcePublications.edges', [])) + ->contains(fn ($pub) => Arr::get($pub, 'node.publication.name') === config('shopify.sales_channel', 'Online Store')); + + if (! $inSalesChannel) { + if ($entry) { + $this->removeOldVariants([], $this->data['handle']); + $entry->delete(); + } + + return; + } + } + // Clean up data whilst checking if product exists $tags = $this->importTaxonomy($this->data['tags'], config('shopify.taxonomies.tags')); $vendors = $this->importTaxonomy([$this->data['vendor']], config('shopify.taxonomies.vendor')); diff --git a/tests/Unit/ImportSingleProductJobTest.php b/tests/Unit/ImportSingleProductJobTest.php index 36fcd1c..f5ba85d 100644 --- a/tests/Unit/ImportSingleProductJobTest.php +++ b/tests/Unit/ImportSingleProductJobTest.php @@ -352,6 +352,118 @@ public function clears_variant_metafields_removed_in_shopify() $this->assertSame([], $variant->get('shopify_metafield_keys')); } + #[Test] + public function skips_import_when_not_in_sales_channel() + { + config(['shopify.import_all_products' => false]); + + Facades\Collection::make(config('shopify.collection_handle', 'products'))->save(); + Facades\Collection::make('variants')->save(); + Facades\Taxonomy::make()->handle('collections')->save(); + Facades\Taxonomy::make()->handle('tags')->save(); + Facades\Taxonomy::make()->handle('type')->save(); + Facades\Taxonomy::make()->handle('vendor')->save(); + + $this->mock(Graphql::class, function (MockInterface $mock) { + $mock->shouldReceive('query')->andReturn(new HttpResponse( + status: 200, + body: $this->getProductJson() + )); + }); + + Jobs\ImportSingleProductJob::dispatch(1); + + $this->assertCount(0, Facades\Entry::whereCollection(config('shopify.collection_handle', 'products'))); + $this->assertCount(0, Facades\Entry::whereCollection('variants')); + } + + #[Test] + public function deletes_existing_product_when_removed_from_sales_channel() + { + config(['shopify.import_all_products' => false]); + + Facades\Collection::make(config('shopify.collection_handle', 'products'))->save(); + Facades\Collection::make('variants')->save(); + Facades\Taxonomy::make()->handle('collections')->save(); + Facades\Taxonomy::make()->handle('tags')->save(); + Facades\Taxonomy::make()->handle('type')->save(); + Facades\Taxonomy::make()->handle('vendor')->save(); + + // First import with the product in the sales channel + $jsonWithChannel = str_replace('"resourcePublications": {}', '"resourcePublications": { + "edges": [ + { + "node": { + "isPublished": true, + "publication": { + "id": "gid://shopify/Publication/1", + "name": "Online Store" + }, + "publishDate": "2024-01-01T00:00:00Z" + } + } + ] + }', $this->getProductJson()); + + $this->mock(Graphql::class, function (MockInterface $mock) use ($jsonWithChannel) { + $mock->shouldReceive('query')->andReturn( + new HttpResponse(status: 200, body: $jsonWithChannel), + new HttpResponse(status: 200, body: $this->getProductJson()) + ); + }); + + Jobs\ImportSingleProductJob::dispatch(1); + + $this->assertCount(1, Facades\Entry::whereCollection(config('shopify.collection_handle', 'products'))); + $this->assertCount(1, Facades\Entry::whereCollection('variants')); + + // Second import with the product no longer in the sales channel + Jobs\ImportSingleProductJob::dispatch(1); + + $this->assertCount(0, Facades\Entry::whereCollection(config('shopify.collection_handle', 'products'))); + $this->assertCount(0, Facades\Entry::whereCollection('variants')); + } + + #[Test] + public function imports_product_when_in_sales_channel_and_filter_enabled() + { + config(['shopify.import_all_products' => false]); + + Facades\Collection::make(config('shopify.collection_handle', 'products'))->save(); + Facades\Taxonomy::make()->handle('collections')->save(); + Facades\Taxonomy::make()->handle('tags')->save(); + Facades\Taxonomy::make()->handle('type')->save(); + Facades\Taxonomy::make()->handle('vendor')->save(); + + $jsonWithChannel = str_replace('"resourcePublications": {}', '"resourcePublications": { + "edges": [ + { + "node": { + "isPublished": true, + "publication": { + "id": "gid://shopify/Publication/1", + "name": "Online Store" + }, + "publishDate": "2024-01-01T00:00:00Z" + } + } + ] + }', $this->getProductJson()); + + $this->mock(Graphql::class, function (MockInterface $mock) use ($jsonWithChannel) { + $mock->shouldReceive('query')->andReturn(new HttpResponse( + status: 200, + body: $jsonWithChannel + )); + }); + + Jobs\ImportSingleProductJob::dispatch(1); + + $entry = Facades\Entry::whereCollection(config('shopify.collection_handle', 'products'))->first(); + $this->assertNotNull($entry); + $this->assertSame($entry->product_id, '108828309'); + } + private function getProductJson(): string { return '{