Przeglądaj źródła

Merge branch 'dev' of http://gogs.hnwmzp.cn/chengwenliang/nshop into dev

llp 3 dni temu
rodzic
commit
eb65f9b32f

+ 1 - 0
config/api-platform.php

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

+ 20 - 0
packages/Webkul/BagistoApi/src/Dto/ProductSearch/ProductSearchEdgeDto.php

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

+ 17 - 0
packages/Webkul/BagistoApi/src/Dto/ProductSearch/ProductSearchPageInfoDto.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\ProductSearch;
+
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(operations: [], graphQlOperations: [])]
+class ProductSearchPageInfoDto
+{
+    public bool $hasNextPage = false;
+
+    public bool $hasPreviousPage = false;
+
+    public ?string $startCursor = null;
+
+    public ?string $endCursor = null;
+}

+ 28 - 0
packages/Webkul/BagistoApi/src/Dto/ProductSearch/SearchMetadataDto.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\ProductSearch;
+
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(operations: [], graphQlOperations: [])]
+class SearchMetadataDto
+{
+    public ?string $originalQuery = null;
+
+    public ?string $effectiveQuery = null;
+
+    public ?string $resultMessage = null;
+
+    public ?string $searchInsteadMessage = null;
+
+    public static function fromArray(array $metadata): self
+    {
+        $dto = new self;
+        $dto->originalQuery = $metadata['originalQuery'] ?? null;
+        $dto->effectiveQuery = $metadata['effectiveQuery'] ?? null;
+        $dto->resultMessage = $metadata['resultMessage'] ?? null;
+        $dto->searchInsteadMessage = $metadata['searchInsteadMessage'] ?? null;
+
+        return $dto;
+    }
+}

+ 46 - 0
packages/Webkul/BagistoApi/src/GraphQl/Serializer/FixedSerializerContextBuilder.php

@@ -15,6 +15,8 @@ use Webkul\BagistoApi\Models\CustomerInvoice;
 use Webkul\BagistoApi\Models\CustomerInvoiceAddress;
 use Webkul\BagistoApi\Models\CustomerOrderShipment;
 use Webkul\BagistoApi\Models\CustomerOrderShipmentItem;
+use Webkul\BagistoApi\Models\Product;
+use Webkul\BagistoApi\Models\ProductSearch;
 
 /**
  * Decorates the GraphQL SerializerContextBuilder to fix attribute resolution
@@ -39,6 +41,21 @@ class FixedSerializerContextBuilder implements SerializerContextBuilderInterface
     {
         $context = $this->decorated->create($resourceClass, $operation, $resolverContext, $normalization);
 
+        /**
+         * ProductSearch is a wrapper object that intentionally contains an
+         * `edges` field. API Platform otherwise mistakes it for a collection
+         * connection and flattens the selection to edges.node, dropping the
+         * wrapper's totalCount, pageInfo, and searchMetadata fields.
+         */
+        if ($normalization && $resourceClass === ProductSearch::class) {
+            $fields = $resolverContext['fields']
+                ?? (($resolverContext['info'] ?? null) instanceof ResolveInfo
+                    ? $resolverContext['info']->getFieldSelection(\PHP_INT_MAX)
+                    : []);
+
+            $context['attributes'] = $this->productSearchAttributes($fields);
+        }
+
         // Ensure nested CustomerOrderItem fields are always included
         // This handles the case where nested items don't trigger their own context builder call
         if ($normalization && $resourceClass === CustomerOrder::class) {
@@ -127,6 +144,35 @@ class FixedSerializerContextBuilder implements SerializerContextBuilderInterface
         return $context;
     }
 
+    private function productSearchAttributes(array $fields): array
+    {
+        $attributes = [];
+
+        foreach ($fields as $field => $selection) {
+            $property = $this->nameConverter
+                ? $this->nameConverter->denormalize((string) $field)
+                : $field;
+
+            if ($field === 'edges' && is_array($selection)) {
+                $edgeSelection = $this->replaceIdKeys($selection, ProductSearch::class, false);
+
+                if (isset($selection['node']) && is_array($selection['node'])) {
+                    $edgeSelection['node'] = $this->replaceIdKeys($selection['node'], Product::class);
+                }
+
+                $attributes[$property] = $edgeSelection;
+
+                continue;
+            }
+
+            $attributes[$property] = is_array($selection)
+                ? $this->replaceIdKeys($selection, ProductSearch::class, false)
+                : $selection;
+        }
+
+        return $attributes;
+    }
+
     /**
      * Ensure nested items' attributes include qty fields
      */

+ 74 - 0
packages/Webkul/BagistoApi/src/Models/ProductSearch.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchEdgeDto;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchPageInfoDto;
+use Webkul\BagistoApi\Dto\ProductSearch\SearchMetadataDto;
+use Webkul\BagistoApi\Resolver\ProductSearchResolver;
+
+#[ApiResource(
+    shortName: 'Search',
+    operations: [],
+    graphQlOperations: [
+        new Query(
+            resolver: ProductSearchResolver::class,
+            args: [
+                'query' => [
+                    'type'        => 'String!',
+                    'description' => 'Product search text.',
+                ],
+                'suggest' => [
+                    'type'        => 'Boolean',
+                    'description' => 'Enable typo correction, fuzzy matching, and query fallback.',
+                ],
+                'filter' => [
+                    'type'        => 'String',
+                    'description' => 'JSON product filter object.',
+                ],
+                'sortKey' => [
+                    'type'        => 'String',
+                    'description' => 'Sort by ID, TITLE, NAME, CREATED_AT, UPDATED_AT, or PRICE.',
+                ],
+                'reverse' => [
+                    'type'        => 'Boolean',
+                    'description' => 'Reverse the selected sort order.',
+                ],
+                'first'   => ['type' => 'Int'],
+                'last'    => ['type' => 'Int'],
+                'after'   => ['type' => 'String'],
+                'before'  => ['type' => 'String'],
+                'locale'  => ['type' => 'String'],
+                'channel' => ['type' => 'String'],
+            ],
+            read: false,
+            paginationEnabled: false,
+            description: 'Search products with inline correction metadata.',
+        ),
+    ],
+    normalizationContext: ['skip_null_values' => false],
+)]
+class ProductSearch
+{
+    public int $total_count = 0;
+
+    /**
+     * @var ProductSearchEdgeDto[]
+     */
+    #[ApiProperty(readableLink: true)]
+    public array $edges = [];
+
+    #[ApiProperty(readableLink: true)]
+    public ProductSearchPageInfoDto $page_info;
+
+    #[ApiProperty(readableLink: true)]
+    public ?SearchMetadataDto $search_metadata = null;
+
+    public function __construct()
+    {
+        $this->page_info = new ProductSearchPageInfoDto;
+    }
+}

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

@@ -27,6 +27,7 @@ use Webkul\BagistoApi\Resolver\CategoryCollectionResolver;
 use Webkul\BagistoApi\Resolver\CustomerQueryResolver;
 use Webkul\BagistoApi\Resolver\Factory\ProductRelationResolverFactory;
 use Webkul\BagistoApi\Resolver\ProductCollectionResolver;
+use Webkul\BagistoApi\Resolver\ProductSearchResolver;
 use Webkul\BagistoApi\Resolver\SingleProductBagistoApiResolver;
 use Webkul\BagistoApi\Resolver\PageByUrlKeyResolver;
 use Webkul\BagistoApi\Routing\CustomIriConverter;
@@ -528,6 +529,7 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->tag(\Webkul\BagistoApi\Resolver\GdprRequestQueryResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(CustomerQueryResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(PageByUrlKeyResolver::class, QueryCollectionResolverInterface::class);
+        $this->app->tag(ProductSearchResolver::class, QueryItemResolverInterface::class);
 
         $this->app->extend(ResolverFactoryInterface::class, function ($resolverFactory, $app) {
             return new ProductRelationResolverFactory(

+ 11 - 0
packages/Webkul/BagistoApi/src/Resolver/Factory/ProductRelationResolverFactory.php

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
 use GraphQL\Type\Definition\ResolveInfo;
 use Webkul\BagistoApi\Dto\CartData;
 use Webkul\BagistoApi\Models\Product;
+use Webkul\BagistoApi\Models\ProductSearch;
 use Webkul\BagistoApi\Models\ReadCart;
 use Webkul\BagistoApi\State\ProductRelationProvider;
 
@@ -46,6 +47,16 @@ class ProductRelationResolverFactory implements ResolverFactoryInterface
 
         return function (?array $source, array $args, $context, ResolveInfo $info) use ($innerResolver, $capturedOperation, $resourceClass, $rootClass) {
 
+            if (
+                $rootClass === ProductSearch::class
+                && $info->fieldName === 'searchMetadata'
+                && is_array($source)
+                && array_key_exists('searchMetadata', $source)
+                && $source['searchMetadata'] === null
+            ) {
+                return null;
+            }
+
             // Handle CartData items field - fetch items fresh from database to avoid denormalization issues
             $isCartContext = in_array($resourceClass, [ReadCart::class, CartData::class], true)
                 || in_array($rootClass, [ReadCart::class, CartData::class], true);

+ 96 - 0
packages/Webkul/BagistoApi/src/Resolver/ProductSearchResolver.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace Webkul\BagistoApi\Resolver;
+
+use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
+use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface;
+use ApiPlatform\State\Pagination\PaginatorInterface;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchEdgeDto;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchPageInfoDto;
+use Webkul\BagistoApi\Dto\ProductSearch\SearchMetadataDto;
+use Webkul\BagistoApi\Models\ProductSearch;
+use Webkul\BagistoApi\State\ProductGraphQLProvider;
+
+class ProductSearchResolver implements QueryItemResolverInterface
+{
+    public function __construct(
+        private readonly ProductGraphQLProvider $productProvider
+    ) {}
+
+    public function __invoke(?object $item, array $context): object
+    {
+        $args = $context['args'] ?? [];
+        $paginator = $this->productProvider->provide(
+            $context['operation'],
+            [],
+            ['args' => $args]
+        );
+
+        if (! $paginator instanceof PaginatorInterface) {
+            throw new \UnexpectedValueException('Product search provider must return a paginator.');
+        }
+
+        $result = new ProductSearch;
+        $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->edges[] = $edge;
+        }
+
+        $result->page_info = $this->buildPageInfo($result->edges, $offset, $paginator);
+
+        $metadata = request()->attributes->get('bagistoapi.search_metadata');
+
+        if (is_array($metadata)) {
+            $result->search_metadata = SearchMetadataDto::fromArray($metadata);
+        }
+
+        request()->attributes->remove('bagistoapi.search_metadata');
+
+        return $result;
+    }
+
+    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;
+    }
+
+    private function buildPageInfo(
+        array $edges,
+        int $offset,
+        PaginatorInterface $paginator
+    ): ProductSearchPageInfoDto {
+        $pageInfo = new ProductSearchPageInfoDto;
+        $pageInfo->hasPreviousPage = $offset > 0;
+        $pageInfo->hasNextPage = $paginator instanceof HasNextPagePaginatorInterface
+            ? $paginator->hasNextPage()
+            : $paginator->getCurrentPage() < $paginator->getLastPage();
+        $pageInfo->startCursor = $edges[0]->cursor ?? null;
+        $pageInfo->endCursor = $edges[array_key_last($edges)]->cursor ?? null;
+
+        return $pageInfo;
+    }
+}

+ 194 - 0
packages/Webkul/BagistoApi/tests/Feature/ProductSearchGraphQLTest.php

@@ -0,0 +1,194 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Feature;
+
+use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Testing\TestResponse;
+use Tests\TestCase;
+use Webkul\BagistoApi\Models\StorefrontKey;
+use Webkul\BagistoApi\Http\Middleware\LogApiRequests;
+
+class ProductSearchGraphQLTest extends TestCase
+{
+    use DatabaseTransactions;
+
+    private string $storefrontKey;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->withoutMiddleware(LogApiRequests::class);
+
+        $this->storefrontKey = StorefrontKey::generateKey();
+
+        StorefrontKey::query()->create([
+            'name'       => 'Product Search GraphQL Test',
+            'key'        => $this->storefrontKey,
+            'is_active'  => true,
+            'rate_limit' => 1000,
+        ]);
+
+        if (
+            core()->getConfigData('catalog.products.search.engine') !== 'elastic'
+            || core()->getConfigData('catalog.products.search.storefront_mode') !== 'elastic'
+        ) {
+            $this->markTestSkipped('The product search GraphQL feature requires the Elasticsearch storefront mode.');
+        }
+    }
+
+    private function graphQL(string $query, array $variables = []): TestResponse
+    {
+        return $this->postJson('/api/graphql', [
+            'query'     => $query,
+            'variables' => $variables,
+        ], [
+            'X-STOREFRONT-KEY' => $this->storefrontKey,
+        ]);
+    }
+
+    public function test_search_returns_correction_metadata_inside_data_and_logs_once(): void
+    {
+        $before = DB::table('search_log')->count();
+
+        $response = $this->graphQL(<<<'GQL'
+            query ProductSearchCorrection {
+              search(query: "readyy", suggest: true, first: 1) {
+                totalCount
+                edges {
+                  cursor
+                  node { id sku name }
+                }
+                pageInfo {
+                  hasNextPage
+                  hasPreviousPage
+                  startCursor
+                  endCursor
+                }
+                searchMetadata {
+                  originalQuery
+                  effectiveQuery
+                  resultMessage
+                  searchInsteadMessage
+                }
+              }
+            }
+            GQL);
+
+        $response
+            ->assertOk()
+            ->assertJsonPath('data.search.searchMetadata.originalQuery', 'readyy')
+            ->assertJsonPath('data.search.searchMetadata.effectiveQuery', 'ready')
+            ->assertJsonMissingPath('extensions.searchMetadata');
+
+        $total = $response->json('data.search.totalCount');
+
+        if (! is_int($total) || $total < 1) {
+            $this->markTestSkipped('The Elasticsearch test index does not contain products matching "ready".');
+        }
+
+        $response->assertJsonStructure([
+            'data' => [
+                'search' => [
+                    'edges' => [['cursor', 'node' => ['id', 'sku', 'name']]],
+                    'pageInfo' => ['hasNextPage', 'hasPreviousPage', 'startCursor', 'endCursor'],
+                ],
+            ],
+        ]);
+
+        $this->assertSame($before + 1, DB::table('search_log')->count());
+        $this->assertDatabaseHas('search_log', [
+            'search_text' => 'readyy',
+            'query_text'  => 'ready',
+            'results'     => $total,
+        ]);
+    }
+
+    public function test_search_without_suggest_returns_null_metadata_and_does_not_log(): void
+    {
+        $before = DB::table('search_log')->count();
+
+        $response = $this->graphQL(<<<'GQL'
+            query ProductSearchWithoutSuggest {
+              search(query: "readyy", suggest: false, first: 1) {
+                totalCount
+                searchMetadata { effectiveQuery }
+              }
+            }
+            GQL);
+
+        $response
+            ->assertOk()
+            ->assertJsonPath('data.search.searchMetadata', null);
+
+        $this->assertIsInt($response->json('data.search.totalCount'));
+        $this->assertSame($before, DB::table('search_log')->count());
+    }
+
+    public function test_search_preserves_filtering_and_cursor_pagination(): void
+    {
+        $page = $this->graphQL(<<<'GQL'
+            query ProductSearchPage {
+              search(query: "ready", first: 1) {
+                totalCount
+                edges { cursor node { sku } }
+                pageInfo { hasNextPage hasPreviousPage }
+              }
+            }
+            GQL);
+
+        $page
+            ->assertOk()
+            ->assertJsonPath('data.search.pageInfo.hasPreviousPage', false);
+
+        $total = $page->json('data.search.totalCount');
+        $cursor = $page->json('data.search.edges.0.cursor');
+        $firstSku = $page->json('data.search.edges.0.node.sku');
+
+        if (! is_int($total) || $total < 1 || ! is_string($cursor) || ! is_string($firstSku)) {
+            $this->markTestSkipped('The Elasticsearch test index does not contain products matching "ready".');
+        }
+
+        $page->assertJsonPath('data.search.pageInfo.hasNextPage', $total > 1);
+
+        if ($total > 1) {
+            $nextPage = $this->graphQL(
+                <<<'GQL'
+                    query ProductSearchNextPage($after: String!) {
+                      search(query: "ready", first: 1, after: $after) {
+                        totalCount
+                        edges { node { sku } }
+                        pageInfo { hasNextPage hasPreviousPage }
+                      }
+                    }
+                    GQL,
+                ['after' => $cursor]
+            );
+
+            $nextPage
+                ->assertOk()
+                ->assertJsonPath('data.search.totalCount', $total)
+                ->assertJsonPath('data.search.pageInfo.hasPreviousPage', true);
+
+            $this->assertNotSame($firstSku, $nextPage->json('data.search.edges.0.node.sku'));
+        }
+
+        $filtered = $this->graphQL(
+            <<<'GQL'
+                query ProductSearchFilter($filter: String!) {
+                  search(query: "ready", filter: $filter, first: 10) {
+                    totalCount
+                    edges { node { sku } }
+                  }
+                }
+                GQL,
+            ['filter' => json_encode(['sku' => $firstSku])]
+        );
+
+        $filtered
+            ->assertOk()
+            ->assertJsonPath('data.search.totalCount', 1)
+            ->assertJsonPath('data.search.edges.0.node.sku', $firstSku);
+    }
+}

+ 105 - 0
packages/Webkul/Shop/src/Http/Controllers/API/OrderController.php

@@ -277,6 +277,111 @@ class OrderController extends APIController
         ];
     }
 
+    /**
+     * 物流轨迹查询
+     *
+     * GET /api/customer/orders/tracking?track_number=xxx
+     *
+     * @param  Request  $request
+     * @return JsonResponse
+     */
+    public function tracking(Request $request): JsonResponse
+    {
+        $trackNumber = $request->input('track_number');
+
+        if (empty($trackNumber)) {
+            return ApiResponse::validationError('track_number is required');
+        }
+        if (mb_strlen($trackNumber) > 100) {
+            return ApiResponse::validationError('track_number must not exceed 100 characters');
+        }
+        $type = $request->input('type');
+        try {
+            $info = $this->gettrack_erp($trackNumber,$type);
+            $data=array();
+            if($info['success']){
+                $data['trackinfo']=$info['track']['data'];
+                return ApiResponse::success($data);
+            }else{
+                return ApiResponse::error($info['msg']);
+            }
+        } catch (\Exception $e) {
+            return ApiResponse::error('Tracking query failed: ' . $e->getMessage());
+        }
+    }
+
+    /**
+     * 调用 ERP 物流查询 API
+     */
+    public function gettrack_erp($track_number,$type)
+    {
+        $data = [
+            'shop'     => 2,
+            'order_no' => $track_number,
+            'time'     => time(),
+            'key'      => $this->getTrackKey(time()),
+        ];
+        if($type){
+            $data['type']=$type;
+        }
+        $datas     = json_encode($data);
+        $requestUrl = 'https://1.wepolicy.cn/apiexpress/logistics';
+        $result    = $this->_getApiData($requestUrl, 'POST', $datas, 1);
+        if(preg_match('/^\xEF\xBB\xBF/',$result))
+        {
+            $result = substr($result,3);
+        }
+        $res = json_decode(trim($result),true);
+        return $res;
+    }
+
+    /**
+     * 生成物流查询加密 Key
+     */
+    public function getTrackKey($time)
+    {
+        $key = "6amg!pnfrlbpnjgirv";
+        $iv = "6ook4k!2w94m6jtm";
+        $serect = "asteriahair";
+        $decrypt = $serect . "+" . $time;
+
+        return openssl_encrypt($decrypt, 'AES-128-CBC', $key, 0, $iv);
+    }
+
+    /**
+     * 通用 HTTP 请求
+     */
+    private function _getApiData($url, $method = 'GET', $data = null, $timeout = 30)
+    {
+        $ch = curl_init();
+
+        curl_setopt($ch, CURLOPT_URL, $url);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
+
+        if (strtoupper($method) === 'POST') {
+            curl_setopt($ch, CURLOPT_POST, true);
+            curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
+            curl_setopt($ch, CURLOPT_HTTPHEADER, [
+                'Content-Type: application/json',
+                'Content-Length: ' . strlen($data),
+            ]);
+        }
+
+        $response = curl_exec($ch);
+        $error    = curl_error($ch);
+
+        curl_close($ch);
+
+        if ($error) {
+            throw new \Exception('CURL Error: ' . $error);
+        }
+
+        return $response;
+    }
+
     /**
      * 获取状态中文标签
      */

+ 1 - 0
packages/Webkul/Shop/src/Routes/api-token.php

@@ -41,6 +41,7 @@ Route::group(['prefix' => 'api'], function () {
         ->prefix('customer/orders')
         ->group(function () {
             Route::get('', 'index')->name('shop.api.token.customer.orders.index');
+            Route::get('tracking', 'tracking')->name('shop.api.token.customer.orders.tracking');
             Route::get('{id}', 'show')->name('shop.api.token.customer.orders.show');
         });