瀏覽代碼

修复BUG

bianjunhui 4 天之前
父節點
當前提交
2a146f3d3e

+ 230 - 0
packages/Webkul/BagistoApi/docs/GRAPHQL_CATEGORY_ATTRIBUTE_FILTERS.md

@@ -0,0 +1,230 @@
+# categoryAttributeFilters 接口说明
+
+分类属性筛选接口,返回某个分类下可用于筛选的属性列表及其可选值(含各选项对应的产品数量)。
+
+## GraphQL 端点
+
+- 端点:`POST /api/graphql`
+- 认证头:`X-STOREFRONT-KEY`
+
+## 查询参数(args)
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `slug` | String | 否 | 分类 slug(如 `ready-to-go-wig`)。不传时返回全部可筛选属性 |
+| `filter` | String | 否 | JSON 格式的已选筛选条件,用于重新计算各选项的产品数量(faceted search 联动) |
+| `first` | Int | 否 | 返回条数(从开头取) |
+| `last` | Int | 否 | 返回条数(从末尾取) |
+| `after` | String | 否 | 分页游标(从某位置之后开始) |
+| `before` | String | 否 | 分页游标(从某位置之前开始) |
+
+### `filter` 参数格式
+
+与 `categoryProducts` 接口一致,JSON 字符串:
+
+```json
+{
+  "wig_color": { "match": "33" },
+  "price_from": 10,
+  "price_to": 212
+}
+```
+
+- 属性筛选:`{"属性code": {"match": "选项ID"}}`
+- 价格区间:`price_from` / `price_to`
+
+---
+
+## 返回结构
+
+```
+categoryAttributeFilters {
+  edges {
+    node { ... }   # 每个可筛选属性
+  }
+  pageInfo { ... } # 分页信息
+}
+```
+
+---
+
+## 返回字段备注
+
+### 顶层
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `edges` | 数组 | 可筛选属性列表(每条是一个属性) |
+| `pageInfo` | 对象 | 分页信息(`hasNextPage`、`endCursor`) |
+
+### `edges.node`(每个属性)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `_id` | Int | 属性数字 ID |
+| `code` | String | 属性编码(唯一标识,如 `wig_color`、`price`、`length`) |
+| `adminName` | String | 属性管理名称(后台配置的名称,如 "Wig Color") |
+| `type` | String | 属性类型(见下方"属性类型") |
+| `swatchType` | String | 色板类型(见下方"色板类型") |
+| `position` | Int | 属性排序位置(数字越小越靠前) |
+| `minPrice` | Float | 价格筛选最小值(仅 `price` 属性有效,其他属性为 0) |
+| `maxPrice` | Float | 价格筛选最大值(仅 `price` 属性有效) |
+| `options` | 数组 | 该属性的可选值列表(连接对象) |
+
+### `options.edges.node`(每个属性选项)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `_id` | Int | 选项数字 ID |
+| `adminName` | String | 选项名称(如 "33"、"Blonde"、"M") |
+| `sortOrder` | Int | 选项排序位置 |
+| `swatchValue` | String | 色板值(含义取决于父属性的 `swatchType`) |
+| `productCount` | Int | 该选项对应的产品数量(faceted search 联动计算) |
+
+---
+
+## 字段值说明
+
+### 属性类型(`type`)
+
+| 值 | 说明 | 前端渲染建议 |
+|----|------|------------|
+| `select` | 下拉单选 | 单选列表 / 颜色筛选 |
+| `multiselect` | 多选 | 多选列表 |
+| `price` | 价格 | 价格区间滑块(用 `minPrice`/`maxPrice`) |
+| `boolean` | 布尔 | 开关 |
+| `text` | 文本 | 文本框 |
+| `textarea` | 多行文本 | 文本域 |
+| `date` / `datetime` | 日期 | 日期选择器 |
+
+### 色板类型(`swatchType`)
+
+| 值 | `swatchValue` 含义 |
+|----|------------------|
+| `color` | 颜色值(如 `#FF0000`),前端渲染为色块 |
+| `image` | 图片路径,前端渲染为图片 |
+| `text` | 文本标签 |
+
+### `productCount` 的联动说明(faceted search)
+
+- 当 `filter` 参数里传入了其他属性的筛选条件时,`productCount` 会根据**剩余匹配产品集**重新计算。
+- 用于实现"每选一个筛选项,其他筛选项的数量自动刷新"的效果。
+- 例如:选了 `wig_color=33` 后,`length` 属性各选项的 `productCount` 只统计"颜色为 33 的假发"。
+
+### `minPrice` / `maxPrice`
+
+- 只在 `price` 属性上有意义,从该分类下产品的价格索引(`product_price_indices`)聚合而来。
+- 用于前端渲染价格筛选区间的上下界。
+- 即使后台没有为分类配置 price 属性,接口也会自动补充一个 price 筛选(`ensurePriceFilter`)。
+
+---
+
+## 完整查询示例
+
+```graphql
+query getCategoryAttributeFilters($slug: String, $filter: String) {
+  categoryAttributeFilters(slug: $slug, filter: $filter) {
+    edges {
+      node {
+        _id
+        code
+        adminName
+        type
+        swatchType
+        position
+        minPrice
+        maxPrice
+        options {
+          edges {
+            node {
+              _id
+              adminName
+              sortOrder
+              swatchValue
+              productCount
+            }
+          }
+        }
+      }
+    }
+    pageInfo {
+      hasNextPage
+      endCursor
+    }
+  }
+}
+```
+
+变量:
+
+```json
+{
+  "slug": "ready-to-go-wig",
+  "filter": "{\"wig_color\":{\"match\":\"33\"},\"price_from\":10,\"price_to\":212}"
+}
+```
+
+---
+
+## 返回示例
+
+```json
+{
+  "data": {
+    "categoryAttributeFilters": {
+      "edges": [
+        {
+          "node": {
+            "_id": 10,
+            "code": "price",
+            "adminName": "Price",
+            "type": "price",
+            "swatchType": null,
+            "position": 1,
+            "minPrice": 10,
+            "maxPrice": 212,
+            "options": { "edges": [] }
+          }
+        },
+        {
+          "node": {
+            "_id": 23,
+            "code": "wig_color",
+            "adminName": "Wig Color",
+            "type": "select",
+            "swatchType": "color",
+            "position": 2,
+            "minPrice": 0,
+            "maxPrice": 0,
+            "options": {
+              "edges": [
+                {
+                  "node": {
+                    "_id": 33,
+                    "adminName": "Blonde",
+                    "sortOrder": 1,
+                    "swatchValue": "#F5DEB3",
+                    "productCount": 12
+                  }
+                }
+              ]
+            }
+          }
+        }
+      ],
+      "pageInfo": {
+        "hasNextPage": false,
+        "endCursor": null
+      }
+    }
+  }
+}
+```
+
+---
+
+## 注意事项
+
+1. **可筛选属性来源**:接口基于分类下产品的实际属性值动态推导(`is_filterable=1` 且产品在该分类下使用了该属性),而非后台 `category_filterable_attributes` 配置表。
+2. **price 属性始终存在**:即使后台未配置,也会自动补充,`minPrice`/`maxPrice` 从产品价格索引计算。
+3. **性能**:每个选项的 `productCount` 通过 SQL 聚合计算,属性/选项较多时建议配合缓存。

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

@@ -341,15 +341,25 @@ class FilterableAttributesProvider implements ProviderInterface
             $column = $this->columnForType($attributeType);
             $term = (string) $spec['match'];
             $matchType = $spec['match_type'] ?? '';
+            $isMultiValue = in_array($attributeType, ['multiselect', 'checkbox'], true);
 
-            $countQuery->whereIn('pav.product_id', function ($sub) use ($code, $column, $term, $matchType) {
+            $countQuery->whereIn('pav.product_id', function ($sub) use ($code, $column, $term, $matchType, $isMultiValue) {
                 $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') {
+                if ($isMultiValue) {
+                    // multiselect/checkbox store comma-separated option ids in text_value.
+                    $values = array_values(array_filter(array_map('trim', explode(',', $term))));
+
+                    $sub->where(function ($q) use ($column, $values) {
+                        foreach ($values as $value) {
+                            $q->orWhereRaw("FIND_IN_SET(?, pav_facet.{$column})", [$value]);
+                        }
+                    });
+                } elseif ($matchType === 'PARTIAL') {
                     $sub->where('pav_facet.'.$column, 'like', "%{$term}%");
                 } elseif (str_contains($term, ',')) {
                     $values = array_values(array_filter(array_map('trim', explode(',', $term))));
@@ -411,7 +421,8 @@ class FilterableAttributesProvider implements ProviderInterface
     {
         return match ($attributeType) {
             'text', 'textarea'  => 'text_value',
-            'select', 'multiselect', 'dropdown' => 'integer_value',
+            'select', 'dropdown' => 'integer_value',
+            'multiselect', 'checkbox' => 'text_value',
             'decimal', 'price' => 'float_value',
             'integer'  => 'integer_value',
             'boolean'  => 'boolean_value',

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

@@ -457,7 +457,8 @@ class ProductGraphQLProvider implements ProviderInterface
     {
         return match ($attributeType) {
             'text', 'textarea'  => 'text_value',
-            'select','multiselect','dropdown' => 'integer_value',
+            'select', 'dropdown' => 'integer_value',
+            'multiselect', 'checkbox' => 'text_value',
             'decimal', 'price' => 'float_value',
             'integer'  => 'integer_value',
             'boolean'  => 'boolean_value',
@@ -484,6 +485,20 @@ class ProductGraphQLProvider implements ProviderInterface
     {
         $column = $this->getColumnForType($attributeType);
 
+        // multiselect/checkbox store comma-separated option ids in text_value,
+        // so match each value via FIND_IN_SET instead of an exact column match.
+        if (in_array($attributeType, ['multiselect', 'checkbox'], true)) {
+            $values = array_filter(array_map('trim', explode(',', (string) $term)));
+
+            $q->where(function ($sub) use ($alias, $column, $values) {
+                foreach ($values as $value) {
+                    $sub->orWhereRaw("FIND_IN_SET(?, {$alias}.{$column})", [$value]);
+                }
+            });
+
+            return;
+        }
+
         if (is_string($term) && str_contains($term, ',')) {
             $values = array_filter(array_map('trim', explode(',', $term)));