bianjunhui hace 1 día
padre
commit
4528730b76

+ 1 - 0
config/api-platform.php

@@ -56,6 +56,7 @@ return [
         base_path('packages/Webkul/BagistoApi/src/Dto/ProductDetail/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/CustomerOrder/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/ProductSearch/'),
+        base_path('packages/Webkul/BagistoApi/src/Dto/CategoryProducts/'),
     ],
 
     'formats' => [

+ 38 - 0
packages/Webkul/BagistoApi/src/Dto/CategoryProducts/AppliedFilterDto.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\CategoryProducts;
+
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(
+    operations: [],
+    graphQlOperations: [],
+    paginationEnabled: false,
+)]
+class AppliedFilterDto
+{
+    /**
+     * Attribute code (e.g. "color", "size", "price").
+     */
+    public ?string $code = null;
+
+    /**
+     * Attribute display label (localized).
+     */
+    public ?string $label = null;
+
+    /**
+     * Selected option values. For select attributes this holds the option id;
+     * for price this holds the selected price range.
+     *
+     * @var list<string>
+     */
+    public array $values = [];
+
+    /**
+     * Human-readable labels for the selected options (when resolvable).
+     *
+     * @var list<string>
+     */
+    public array $value_labels = [];
+}

+ 20 - 0
packages/Webkul/BagistoApi/src/Dto/CategoryProducts/CategoryFilterEdgeDto.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\CategoryProducts;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use Webkul\BagistoApi\Models\Filter\Attribute;
+
+#[ApiResource(
+    operations: [],
+    graphQlOperations: [],
+    paginationEnabled: false,
+)]
+class CategoryFilterEdgeDto
+{
+    public ?string $cursor = null;
+
+    #[ApiProperty(readableLink: true)]
+    public ?Attribute $node = null;
+}

+ 0 - 0
packages/Webkul/BagistoApi/src/Dto/CategoryProducts/CategoryProductsDto.php


+ 73 - 0
packages/Webkul/BagistoApi/src/Models/CategoryProducts.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use Webkul\BagistoApi\Dto\CategoryProducts\AppliedFilterDto;
+use Webkul\BagistoApi\Dto\CategoryProducts\CategoryFilterEdgeDto;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchEdgeDto;
+use Webkul\BagistoApi\Resolver\CategoryProductsResolver;
+
+#[ApiResource(
+    shortName: 'CategoryProducts',
+    operations: [],
+    graphQlOperations: [
+        new Query(
+            resolver: CategoryProductsResolver::class,
+            args: [
+                'categorySlug' => [
+                    'type'        => 'String!',
+                    'description' => 'Category slug to list products for.',
+                ],
+                'filter' => [
+                    'type'        => 'String',
+                    'description' => 'JSON filter object containing attribute filters. Example: {"color":{"match":"2"},"size":{"match":"M"}}',
+                ],
+                'first'   => ['type' => 'Int'],
+                'last'    => ['type' => 'Int'],
+                'after'   => ['type' => 'String'],
+                'before'  => ['type' => 'String'],
+                'locale'  => ['type' => 'String'],
+                'channel' => ['type' => 'String'],
+            ],
+            read: false,
+            paginationEnabled: false,
+            description: 'List products in a category with applied/available attribute filters.',
+        ),
+    ],
+    normalizationContext: ['skip_null_values' => false],
+)]
+class CategoryProducts
+{
+    /**
+     * Total number of products matching the current filters.
+     */
+    public int $total_count = 0;
+
+    /**
+     * Product list for the current page.
+     *
+     * @var list<ProductSearchEdgeDto>
+     */
+    #[ApiProperty(readableLink: true)]
+    public array $products = [];
+
+    /**
+     * Filters the client has currently applied (resolved from the `filter` arg).
+     *
+     * @var list<AppliedFilterDto>
+     */
+    #[ApiProperty(readableLink: true)]
+    public array $applied_filters = [];
+
+    /**
+     * All filterable attributes for the category, each with option-level
+     * product counts — equivalent to the `categoryAttributeFilters` query.
+     *
+     * @var list<CategoryFilterEdgeDto>
+     */
+    #[ApiProperty(readableLink: true)]
+    public array $available_filters = [];
+}

+ 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],
+                'filter'       => ['type' => 'String', 'description' => 'JSON attribute filters to recompute option product counts against (faceted search).'],
                 '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

@@ -25,6 +25,7 @@ use Webkul\BagistoApi\Repositories\GuestCartTokensRepository;
 use Webkul\BagistoApi\Resolver\BaseQueryItemResolver;
 use Webkul\BagistoApi\Resolver\CategoryCollectionResolver;
 use Webkul\BagistoApi\Resolver\CustomerQueryResolver;
+use Webkul\BagistoApi\Resolver\CategoryProductsResolver;
 use Webkul\BagistoApi\Resolver\Factory\ProductRelationResolverFactory;
 use Webkul\BagistoApi\Resolver\ProductCollectionResolver;
 use Webkul\BagistoApi\Resolver\ProductSearchResolver;
@@ -564,6 +565,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(CategoryProductsResolver::class, QueryItemResolverInterface::class);
 
         $this->app->extend(ResolverFactoryInterface::class, function ($resolverFactory, $app) {
             return new ProductRelationResolverFactory(

+ 288 - 0
packages/Webkul/BagistoApi/src/Resolver/CategoryProductsResolver.php

@@ -0,0 +1,288 @@
+<?php
+
+namespace Webkul\BagistoApi\Resolver;
+
+use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\State\Pagination\PaginatorInterface;
+use Illuminate\Support\Facades\DB;
+use Webkul\BagistoApi\Dto\CategoryProducts\AppliedFilterDto;
+use Webkul\BagistoApi\Dto\CategoryProducts\CategoryFilterEdgeDto;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchEdgeDto;
+use Webkul\BagistoApi\Models\CategoryProducts;
+use Webkul\BagistoApi\State\FilterableAttributesProvider;
+use Webkul\BagistoApi\State\ProductGraphQLProvider;
+
+class CategoryProductsResolver implements QueryItemResolverInterface
+{
+    public function __construct(
+        private readonly ProductGraphQLProvider $productProvider,
+        private readonly FilterableAttributesProvider $filterProvider,
+    ) {}
+
+    public function __invoke(?object $item, array $context): object
+    {
+        $args = $context['args'] ?? [];
+
+        $categorySlug = $args['categorySlug'] ?? null;
+
+        // When a collection field (appliedFilters / availableFilters) is empty,
+        // API Platform's ResolverFactory treats the empty array as "not yet
+        // fetched" and re-invokes the item resolver for that sub-field. Guard
+        // against this by returning an empty result for non-root field names.
+        $fieldName = $context['info']->fieldName ?? 'categoryProducts';
+        if (! $categorySlug || $fieldName !== 'categoryProducts') {
+            return new CategoryProducts;
+        }
+
+        $categoryId = DB::table('category_translations')
+            ->where('slug', $categorySlug)
+            ->value('category_id');
+
+        if (! $categoryId) {
+            throw new \RuntimeException("Category not found for slug: {$categorySlug}");
+        }
+
+        // 1. Products — reuse the product provider with category + filters and
+        //    default price-ascending order.
+        $filters = $this->parseFilter($args['filter'] ?? null);
+        $filters['category_id'] = (int) $categoryId;
+
+        $productArgs = $args;
+        $productArgs['filter'] = json_encode($filters);
+        // Default sort: cheapest first (sortKey PRICE, ascending).
+        if (! isset($productArgs['sortKey'])) {
+            $productArgs['sortKey'] = 'PRICE';
+            $productArgs['reverse'] = false;
+        }
+
+        $paginator = $this->productProvider->provide(
+            $context['operation'],
+            [],
+            ['args' => $productArgs]
+        );
+
+        if (! $paginator instanceof PaginatorInterface) {
+            throw new \UnexpectedValueException('Product provider must return a paginator.');
+        }
+
+        $result = new CategoryProducts;
+        $result->total_count = (int) $paginator->getTotalItems();
+
+        $offset = $this->resolveOffset($args, $result->total_count);
+
+        foreach ($paginator as $index => $product) {
+            $edge = new ProductSearchEdgeDto;
+            $edge->cursor = base64_encode((string) ($offset + $index));
+            $edge->node = $product;
+            $result->products[] = $edge;
+        }
+
+        // 2. Available filters — reuse the filter provider (returns attributes
+        //    with option-level product counts for the category). The client's
+        //    selected filters are passed through so facet counts reflect the
+        //    remaining result set (excluding each attribute's own selection).
+        $filterArgs = [
+            'categorySlug' => $categorySlug,
+            'filter'       => $args['filter'] ?? null,
+        ];
+
+        $filterPaginator = $this->filterProvider->provide(
+            new GetCollection,
+            [],
+            ['source' => null, 'args' => $filterArgs, 'info' => null]
+        );
+
+        foreach ($filterPaginator as $index => $attribute) {
+            $edge = new CategoryFilterEdgeDto;
+            $edge->cursor = base64_encode((string) $index);
+            $edge->node = $attribute;
+            $result->available_filters[] = $edge;
+        }
+
+        // 3. Applied filters — resolve the client's current selection into a
+        //    structured list (code + label + option ids + option labels).
+        $result->applied_filters = $this->buildAppliedFilters($filters);
+
+        return $result;
+    }
+
+    /**
+     * Resolve the current page offset from Relay cursor args.
+     */
+    private function resolveOffset(array $args, int $total): int
+    {
+        $limit = max(1, (int) ($args['first'] ?? $args['last'] ?? 30));
+        $offset = 0;
+
+        if (! empty($args['after'])) {
+            $decoded = base64_decode($args['after'], true);
+            $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
+        }
+
+        if (! empty($args['before'])) {
+            $decoded = base64_decode($args['before'], true);
+            $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
+            $offset = max(0, $cursor - $limit);
+        }
+
+        if ($offset > $total) {
+            $offset = max(0, $total - $limit);
+        }
+
+        return $offset;
+    }
+
+    /**
+     * Parse the `filter` arg (JSON string or already-decoded array) into an
+     * associative array of filters keyed by attribute code.
+     */
+    private function parseFilter(mixed $filter): array
+    {
+        if (empty($filter)) {
+            return [];
+        }
+
+        if (is_string($filter)) {
+            $decoded = json_decode($filter, true);
+
+            return is_array($decoded) ? $decoded : [];
+        }
+
+        return is_array($filter) ? $filter : [];
+    }
+
+    /**
+     * Build the structured "applied filters" list from the raw filter map.
+     *
+     * @param  array<string, mixed>  $filters
+     * @return list<AppliedFilterDto>
+     */
+    private function buildAppliedFilters(array $filters): array
+    {
+        if (empty($filters)) {
+            return [];
+        }
+
+        // Non-attribute keys handled specially (price range) or ignored
+        // (already folded into the product query / pagination).
+        $ignored = ['category_id', 'type', 'sku', 'new', 'featured', 'pageSize', 'first', 'last', 'after', 'before'];
+
+        $price = [];
+        if (isset($filters['price_from'])) {
+            $price['from'] = (float) $filters['price_from'];
+            unset($filters['price_from']);
+        }
+        if (isset($filters['price_to'])) {
+            $price['to'] = (float) $filters['price_to'];
+            unset($filters['price_to']);
+        }
+
+        $applied = [];
+
+        foreach ($filters as $code => $spec) {
+            if (in_array($code, $ignored, true)) {
+                continue;
+            }
+
+            $values = $this->extractValues($spec);
+            if (empty($values)) {
+                continue;
+            }
+
+            $applied[] = $this->makeFilterDto($code, $values);
+        }
+
+        if (! empty($price)) {
+            $dto = new AppliedFilterDto;
+            $dto->code = 'price';
+
+            $priceAttribute = DB::table('attributes')->where('code', 'price')->first();
+            $dto->label = $priceAttribute?->admin_name ?? 'Price';
+
+            $dto->values = [];
+            $dto->value_labels = [];
+
+            if (isset($price['from'])) {
+                $dto->values[] = (string) $price['from'];
+                $dto->value_labels[] = (string) $price['from'];
+            }
+
+            if (isset($price['to'])) {
+                $dto->values[] = (string) $price['to'];
+                $dto->value_labels[] = (string) $price['to'];
+            }
+
+            $applied[] = $dto;
+        }
+
+        return $applied;
+    }
+
+    /**
+     * Extract the selected option id(s) from a filter spec. Accepts:
+     *   "2", 2, ["2","3"], {"match": "2"}, {"match": "2,3"}
+     *
+     * @return list<string>
+     */
+    private function extractValues(mixed $spec): array
+    {
+        $raw = $spec;
+
+        if (is_array($spec)) {
+            if (isset($spec['match'])) {
+                $raw = $spec['match'];
+            } elseif (array_is_list($spec)) {
+                $raw = $spec;
+            }
+        }
+
+        if (is_array($raw)) {
+            $values = [];
+            foreach ($raw as $v) {
+                foreach (explode(',', (string) $v) as $part) {
+                    $part = trim($part);
+                    if ($part !== '') {
+                        $values[] = $part;
+                    }
+                }
+            }
+
+            return array_values(array_unique($values));
+        }
+
+        $values = array_filter(array_map('trim', explode(',', (string) $raw)));
+
+        return array_values(array_unique($values));
+    }
+
+    /**
+     * Build an AppliedFilterDto with the attribute label and option labels
+     * resolved from the database.
+     *
+     * @param  list<string>  $values
+     */
+    private function makeFilterDto(string $code, array $values): AppliedFilterDto
+    {
+        $dto = new AppliedFilterDto;
+        $dto->code = $code;
+        $dto->values = $values;
+
+        $attribute = DB::table('attributes')->where('code', $code)->first();
+        if ($attribute) {
+            $dto->label = $attribute->admin_name;
+
+            $optionLabels = DB::table('attribute_options')
+                ->where('attribute_id', $attribute->id)
+                ->whereIn('id', $values)
+                ->pluck('admin_name', 'id');
+
+            $dto->value_labels = [];
+            foreach ($values as $value) {
+                $dto->value_labels[] = $optionLabels[$value] ?? $value;
+            }
+        }
+
+        return $dto;
+    }
+}

+ 145 - 3
packages/Webkul/BagistoApi/src/State/FilterableAttributesProvider.php

@@ -13,6 +13,8 @@ use Webkul\BagistoApi\Models\Product;
 
 class FilterableAttributesProvider implements ProviderInterface
 {
+    private ?array $attributeTypeCache = null;
+
     public function __construct(
         private readonly Pagination $pagination
     ) {}
@@ -25,6 +27,11 @@ class FilterableAttributesProvider implements ProviderInterface
 
         $categorySlug = $args['categorySlug'] ?? null;
 
+        // Active attribute filters (faceted search). When a filter is already
+        // selected for one attribute, the product counts of every other
+        // attribute are recomputed against the remaining product set.
+        $activeFilters = $this->parseActiveFilters($args['filter'] ?? null);
+
         $first = isset($args['first']) ? (int) $args['first'] : null;
         $last = isset($args['last']) ? (int) $args['last'] : null;
         $after = $args['after'] ?? null;
@@ -92,11 +99,11 @@ class FilterableAttributesProvider implements ProviderInterface
             ->limit($perPage)
             ->get();
 
-        $items = $items->map(function ($item) use ($maxPrice, $categoryId) {
+        $items = $items->map(function ($item) use ($maxPrice, $categoryId, $activeFilters) {
             $item->maxPrice = (float) $maxPrice;
             $item->minPrice = 0.0;
 
-            $this->attachProductCounts($item, $categoryId);
+            $this->attachProductCounts($item, $categoryId, $activeFilters);
 
             return $item;
         });
@@ -120,8 +127,15 @@ class FilterableAttributesProvider implements ProviderInterface
      *
      * Visibility mirrors ProductGraphQLProvider: a product is counted only when
      * it has status=1 (attribute id 8) and visible_individually=1 (attribute id 7).
+     *
+     * Faceted search: when other attributes already have an active filter, the
+     * counts are restricted to the products matching those filters, so each
+     * option count reflects the remaining result set (excluding the current
+     * attribute's own filter so its options stay selectable).
+     *
+     * @param  array<string, array<string, mixed>>  $activeFilters  attribute code => filter spec
      */
-    private function attachProductCounts($item, ?int $categoryId): void
+    private function attachProductCounts($item, ?int $categoryId, array $activeFilters = []): void
     {
         $options = $item->options ?? $item->options()->get();
 
@@ -158,6 +172,8 @@ class FilterableAttributesProvider implements ProviderInterface
             });
         }
 
+        $this->applyActiveFilters($countQuery, $activeFilters, $item->code);
+
         $optionIds = $options->pluck('id')->map(fn ($id) => (string) $id)->all();
 
         if ($column === 'integer_value') {
@@ -207,4 +223,130 @@ class FilterableAttributesProvider implements ProviderInterface
 
         return $counts;
     }
+
+    /**
+     * Parse the `filter` arg (JSON string or array) into a map of attribute
+     * code => filter spec. Matches the format used by ProductGraphQLProvider:
+     *   {"color":{"match":"2","match_type":"exact"}}
+     *   {"color":"2"}
+     *
+     * @return array<string, array<string, mixed>>
+     */
+    private function parseActiveFilters(mixed $filter): array
+    {
+        if (empty($filter)) {
+            return [];
+        }
+
+        if (is_string($filter)) {
+            $decoded = json_decode($filter, true);
+
+            $filter = is_array($decoded) ? $decoded : [];
+        }
+
+        if (! is_array($filter)) {
+            return [];
+        }
+
+        $active = [];
+
+        foreach ($filter as $code => $spec) {
+            if (is_array($spec)) {
+                if (isset($spec['match'])) {
+                    $active[$code] = [
+                        'match'      => $spec['match'],
+                        'match_type' => strtoupper($spec['match_type'] ?? ''),
+                    ];
+                } elseif (array_is_list($spec)) {
+                    $active[$code] = ['match' => implode(',', $spec), 'match_type' => ''];
+                }
+            } else {
+                $active[$code] = ['match' => (string) $spec, 'match_type' => ''];
+            }
+        }
+
+        return $active;
+    }
+
+    /**
+     * Apply the active filters of other attributes to a facet count query.
+     *
+     * For each active attribute filter (excluding the attribute currently being
+     * counted), add an EXISTS sub-query restricting to products whose attribute
+     * value matches the selection. This keeps the counts consistent with the
+     * product listing that used the same filters.
+     *
+     * @param  array<string, array<string, mixed>>  $activeFilters
+     */
+    private function applyActiveFilters($countQuery, array $activeFilters, ?string $currentCode): void
+    {
+        if (empty($activeFilters)) {
+            return;
+        }
+
+        $attributeTypes = $this->getAttributeTypeCache();
+
+        foreach ($activeFilters as $code => $spec) {
+            // Exclude the current attribute so its own options remain selectable.
+            if ($code === $currentCode) {
+                continue;
+            }
+
+            $attributeType = $attributeTypes[$code] ?? 'text';
+            $column = $this->columnForType($attributeType);
+            $term = (string) $spec['match'];
+            $matchType = $spec['match_type'] ?? '';
+
+            $countQuery->whereIn('pav.product_id', function ($sub) use ($code, $column, $term, $matchType) {
+                $sub->select('product_id')
+                    ->from('product_attribute_values as pav_facet')
+                    ->where('pav_facet.attribute_id', function ($q) use ($code) {
+                        $q->select('id')->from('attributes')->where('code', $code);
+                    });
+
+                if ($matchType === 'PARTIAL') {
+                    $sub->where('pav_facet.'.$column, 'like', "%{$term}%");
+                } elseif (str_contains($term, ',')) {
+                    $values = array_values(array_filter(array_map('trim', explode(',', $term))));
+                    $sub->whereIn('pav_facet.'.$column, $values);
+                } else {
+                    $sub->where('pav_facet.'.$column, $term);
+                }
+            });
+        }
+    }
+
+    /**
+     * Get the attribute code => type map, cached for the request.
+     *
+     * @return array<string, string>
+     */
+    private function getAttributeTypeCache(): array
+    {
+        if ($this->attributeTypeCache === null) {
+            $this->attributeTypeCache = DB::table('attributes')
+                ->pluck('type', 'code')
+                ->toArray();
+        }
+
+        return $this->attributeTypeCache;
+    }
+
+    /**
+     * Map an attribute type to the product_attribute_values column holding its value.
+     */
+    private function columnForType(string $attributeType): string
+    {
+        return match ($attributeType) {
+            'text', 'textarea'  => 'text_value',
+            'select', 'multiselect', 'dropdown' => 'integer_value',
+            'decimal', 'price' => 'float_value',
+            'integer'  => 'integer_value',
+            'boolean'  => 'boolean_value',
+            'datetime' => 'datetime_value',
+            'date'     => 'date_value',
+            'json'     => 'json_value',
+            default    => 'text_value',
+        };
+    }
 }

+ 2 - 1
packages/Webkul/BagistoApi/src/State/ProductGraphQLProvider.php

@@ -144,7 +144,8 @@ class ProductGraphQLProvider implements ProviderInterface
                             ->findOneByField('code', 'guest');
 
                     $query->leftJoin('product_price_indices', function ($join) use ($customerGroup) {
-                        $join->on('products.id', '=', 'product_price_indices.product_id')
+                        $join->on('products.id', '=', 'product_price_indices.priceable_id')
+                            ->where('product_price_indices.priceable_type', '=', \Webkul\Product\Models\Product::class)
                             ->where('product_price_indices.customer_group_id', $customerGroup->id);
                     })
                         ->orderBy('product_price_indices.min_price', $direction)