bianjunhui 1 dzień temu
rodzic
commit
32cc678979

+ 10 - 0
packages/Webkul/BagistoApi/src/Models/AttributeOption.php

@@ -63,4 +63,14 @@ class AttributeOption extends \Webkul\Attribute\Models\AttributeOption
         return $this->translation;
     }
 
+    /**
+     * Number of visible products associated with this option within the
+     * current category context. Populated by FilterableAttributesProvider.
+     */
+    #[ApiProperty(writable: false, readable: true)]
+    public function getProductCount(): int
+    {
+        return (int) ($this->product_count ?? 0);
+    }
+
 }

+ 68 - 0
packages/Webkul/BagistoApi/src/Models/CategoryFilters.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use Webkul\BagistoApi\Resolver\CategoryFiltersResolver;
+
+#[ApiResource(
+    shortName: 'CategoryFilters',
+    description: 'Category page aggregate: filterable attributes plus the paginated product list.',
+    operations: [],
+    graphQlOperations: [
+        new Query(
+            resolver: CategoryFiltersResolver::class,
+            args: [
+                'categorySlug' => ['type' => 'String', 'required' => false, 'description' => 'Category slug (e.g. "ready-to-go-wig").'],
+                'categoryId'   => ['type' => 'Int', 'required' => false, 'description' => 'Category id. Takes precedence over slug.'],
+                'filter'       => ['type' => 'String', 'required' => false, 'description' => 'JSON attribute filter object to narrow products, e.g. {"color":"1","size":"2,3"}.'],
+                'filterFirst'  => ['type' => 'Int', 'description' => 'Number of filter attributes to return.'],
+                'page'         => ['type' => 'Int', 'required' => false, 'description' => 'Product page number (1-based). Defaults to 1.'],
+                'pageSize'     => ['type' => 'Int', 'required' => false, 'description' => 'Number of products per page. Defaults to 30.'],
+            ],
+            read: false,
+            paginationEnabled: false,
+        ),
+    ],
+    normalizationContext: ['skip_null_values' => false],
+)]
+class CategoryFilters
+{
+    /**
+     * Filterable attributes for the requested category.
+     *
+     * @var \Webkul\BagistoApi\Models\Filter\Attribute[]
+     */
+    #[ApiProperty(readableLink: true)]
+    public array $filters = [];
+
+    /**
+     * Total number of products matching the category and applied filters.
+     */
+    public int $totalCount = 0;
+
+    /**
+     * Current product page number (1-based).
+     */
+    public int $currentPage = 1;
+
+    /**
+     * Last available product page number.
+     */
+    public int $lastPage = 1;
+
+    /**
+     * Number of products per page.
+     */
+    public int $pageSize = 30;
+
+    /**
+     * Products for the current page.
+     *
+     * @var Product[]
+     */
+    #[ApiProperty(readableLink: true)]
+    public array $products = [];
+}

+ 1 - 0
packages/Webkul/BagistoApi/src/Models/Filter/Attribute.php

@@ -17,6 +17,7 @@ use Webkul\BagistoApi\State\FilterableAttributesProvider;
             provider: FilterableAttributesProvider::class,
             args: [
                 'categorySlug' => ['type' => 'String', 'required' => false],
+                'categoryId'   => ['type' => 'Int', 'required' => false],
                 'first'        => ['type' => 'Int', 'description' => 'Number of items to return from the start'],
                 'last'         => ['type' => 'Int', 'description' => 'Number of items to return from the end'],
                 'after'        => ['type' => 'String', 'description' => 'Cursor to start pagination after'],

+ 2 - 0
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -24,6 +24,7 @@ use Webkul\BagistoApi\OpenApi\SplitOpenApiFactory;
 use Webkul\BagistoApi\Repositories\GuestCartTokensRepository;
 use Webkul\BagistoApi\Resolver\BaseQueryItemResolver;
 use Webkul\BagistoApi\Resolver\CategoryCollectionResolver;
+use Webkul\BagistoApi\Resolver\CategoryFiltersResolver;
 use Webkul\BagistoApi\Resolver\CustomerQueryResolver;
 use Webkul\BagistoApi\Resolver\Factory\ProductRelationResolverFactory;
 use Webkul\BagistoApi\Resolver\ProductCollectionResolver;
@@ -560,6 +561,7 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->tag(CustomerQueryResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(PageByUrlKeyResolver::class, QueryCollectionResolverInterface::class);
         $this->app->tag(ProductSearchResolver::class, QueryItemResolverInterface::class);
+        $this->app->tag(CategoryFiltersResolver::class, QueryItemResolverInterface::class);
 
         $this->app->extend(ResolverFactoryInterface::class, function ($resolverFactory, $app) {
             return new ProductRelationResolverFactory(

+ 132 - 0
packages/Webkul/BagistoApi/src/Resolver/CategoryFiltersResolver.php

@@ -0,0 +1,132 @@
+<?php
+
+namespace Webkul\BagistoApi\Resolver;
+
+use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
+use ApiPlatform\State\Pagination\PaginatorInterface;
+use Illuminate\Support\Facades\DB;
+use UnexpectedValueException;
+use Webkul\BagistoApi\Models\CategoryFilters;
+use Webkul\BagistoApi\State\FilterableAttributesProvider;
+use Webkul\BagistoApi\State\ProductGraphQLProvider;
+
+class CategoryFiltersResolver implements QueryItemResolverInterface
+{
+    public function __construct(
+        private readonly FilterableAttributesProvider $filterableAttributesProvider,
+        private readonly ProductGraphQLProvider $productProvider
+    ) {}
+
+    public function __invoke(?object $item, array $context): object
+    {
+        $args = $context['args'] ?? [];
+
+        $categoryId = $this->resolveCategoryId($args);
+
+        $result = new CategoryFilters;
+
+        // Resolve filterable attributes.
+        $filtersPaginator = $this->filterableAttributesProvider->provide(
+            $context['operation'],
+            [],
+            [
+                'args' => [
+                    'categorySlug' => $args['categorySlug'] ?? null,
+                    'categoryId'   => $categoryId,
+                    'first'        => $args['filterFirst'] ?? null,
+                ],
+            ]
+        );
+
+        if ($filtersPaginator instanceof PaginatorInterface) {
+            foreach ($filtersPaginator as $attribute) {
+                $result->filters[] = $attribute;
+            }
+        }
+
+        // Resolve products with page-based pagination.
+        $pageSize = max(1, (int) ($args['pageSize'] ?? 30));
+        $page = max(1, (int) ($args['page'] ?? 1));
+
+        $offset = ($page - 1) * $pageSize;
+
+        $productArgs = [
+            'first' => $pageSize,
+        ];
+
+        // ProductGraphQLProvider turns an "after" cursor into offset = cursor + 1,
+        // so encoding (offset - 1) yields the exact target offset.
+        if ($offset > 0) {
+            $productArgs['after'] = base64_encode((string) ($offset - 1));
+        }
+
+        $productArgs['filter'] = $this->buildProductFilter($args['filter'] ?? null, $categoryId);
+
+        $productsPaginator = $this->productProvider->provide(
+            $context['operation'],
+            [],
+            ['args' => $productArgs]
+        );
+
+        if (! $productsPaginator instanceof PaginatorInterface) {
+            throw new UnexpectedValueException('Product provider must return a paginator.');
+        }
+
+        $result->totalCount = (int) $productsPaginator->getTotalItems();
+        $result->pageSize = $pageSize;
+        $result->currentPage = $page;
+        $result->lastPage = $result->totalCount > 0
+            ? (int) ceil($result->totalCount / $pageSize)
+            : 1;
+
+        foreach ($productsPaginator as $product) {
+            $result->products[] = $product;
+        }
+
+        return $result;
+    }
+
+    /**
+     * Merge the user-provided attribute filter with the resolved category id.
+     *
+     * The resulting JSON string is passed to ProductGraphQLProvider, which
+     * applies category_id + any attribute filters (color, size, etc.).
+     */
+    private function buildProductFilter(?string $filter, ?int $categoryId): ?string
+    {
+        $filters = [];
+
+        if ($filter) {
+            $decoded = json_decode($filter, true);
+
+            if (is_array($decoded)) {
+                $filters = $decoded;
+            }
+        }
+
+        if ($categoryId) {
+            $filters['category_id'] = $categoryId;
+        }
+
+        return empty($filters) ? null : json_encode($filters);
+    }
+
+    private function resolveCategoryId(array $args): ?int
+    {
+        if (isset($args['categoryId'])) {
+            return (int) $args['categoryId'];
+        }
+
+        if (! empty($args['categorySlug'])) {
+            $categoryId = DB::table('category_translations')
+                ->where('slug', $args['categorySlug'])
+                ->select('category_id')
+                ->pluck('category_id')
+                ->first();
+
+            return $categoryId ? (int) $categoryId : null;
+        }
+
+        return null;
+    }
+}

+ 95 - 4
packages/Webkul/BagistoApi/src/State/FilterableAttributesProvider.php

@@ -24,6 +24,7 @@ class FilterableAttributesProvider implements ProviderInterface
         $info = $context['info'] ?? null;
 
         $categorySlug = $args['categorySlug'] ?? null;
+        $categoryId = isset($args['categoryId']) ? (int) $args['categoryId'] : null;
 
         $first = isset($args['first']) ? (int) $args['first'] : null;
         $last = isset($args['last']) ? (int) $args['last'] : null;
@@ -49,9 +50,13 @@ class FilterableAttributesProvider implements ProviderInterface
 
         $query->select('attributes.*');
 
-        $categoryId = $categorySlug
-            ? DB::table('category_translations')->where('slug', $categorySlug)->select('category_id')->pluck('category_id')->first()
-            : null;
+        if (! $categoryId && $categorySlug) {
+            $categoryId = DB::table('category_translations')
+                ->where('slug', $categorySlug)
+                ->select('category_id')
+                ->pluck('category_id')
+                ->first();
+        }
 
         if ($categoryId) {
             $query
@@ -92,10 +97,12 @@ class FilterableAttributesProvider implements ProviderInterface
             ->limit($perPage)
             ->get();
 
-        $items = $items->map(function ($item) use ($maxPrice) {
+        $items = $items->map(function ($item) use ($maxPrice, $categoryId) {
             $item->maxPrice = (float) $maxPrice;
             $item->minPrice = 0.0;
 
+            $this->attachProductCounts($item, $categoryId);
+
             return $item;
         });
 
@@ -111,4 +118,88 @@ class FilterableAttributesProvider implements ProviderInterface
             )
         );
     }
+
+    /**
+     * Attach the number of visible products associated with each option of
+     * the given attribute within the requested category context.
+     *
+     * Visibility mirrors ProductGraphQLProvider: a product is counted only when
+     * it has status=1 (attribute id 8) and visible_individually=1 (attribute id 7).
+     */
+    private function attachProductCounts($item, ?int $categoryId): void
+    {
+        $options = $item->options ?? $item->options()->get();
+
+        if ($options->isEmpty()) {
+            return;
+        }
+
+        $attributeId = $item->id;
+        $column = $item->type === 'select' ? 'integer_value' : 'text_value';
+
+        $countQuery = DB::table('product_attribute_values as pav')
+            ->join('product_attribute_values as pav_status', function ($join) {
+                $join->on('pav_status.product_id', '=', 'pav.product_id')
+                    ->where('pav_status.attribute_id', 8)
+                    ->where('pav_status.boolean_value', 1);
+            })
+            ->join('product_attribute_values as pav_visible', function ($join) {
+                $join->on('pav_visible.product_id', '=', 'pav.product_id')
+                    ->where('pav_visible.attribute_id', 7)
+                    ->where('pav_visible.boolean_value', 1);
+            })
+            ->where('pav.attribute_id', $attributeId);
+
+        if ($categoryId) {
+            $countQuery->whereIn('pav.product_id', function ($sub) use ($categoryId) {
+                $sub->select('product_id')
+                    ->from('product_categories')
+                    ->where('category_id', $categoryId);
+            });
+        }
+
+        $optionIds = $options->pluck('id')->map(fn ($id) => (string) $id)->all();
+
+        if ($column === 'integer_value') {
+            $counts = (clone $countQuery)
+                ->whereIn('pav.integer_value', $optionIds)
+                ->selectRaw('pav.integer_value as option_id, COUNT(DISTINCT pav.product_id) as total')
+                ->groupBy('pav.integer_value')
+                ->pluck('total', 'option_id')
+                ->toArray();
+        } else {
+            $counts = $this->countMultiSelectOptions(clone $countQuery, $optionIds);
+        }
+
+        foreach ($options as $option) {
+            $option->product_count = (int) ($counts[(string) $option->id] ?? 0);
+        }
+    }
+
+    /**
+     * Count products for multiselect/checkbox options whose values are stored
+     * as comma-separated option ids in the text_value column.
+     */
+    private function countMultiSelectOptions($query, array $optionIds): array
+    {
+        $rows = $query
+            ->whereNotNull('pav.text_value')
+            ->selectRaw('pav.text_value, COUNT(DISTINCT pav.product_id) as total')
+            ->groupBy('pav.text_value')
+            ->get();
+
+        $counts = [];
+
+        foreach ($rows as $row) {
+            $values = array_filter(array_map('trim', explode(',', (string) $row->text_value)));
+
+            foreach ($values as $value) {
+                if (in_array($value, $optionIds, true)) {
+                    $counts[$value] = ($counts[$value] ?? 0) + (int) $row->total;
+                }
+            }
+        }
+
+        return $counts;
+    }
 }