chengwl 1 неделя назад
Родитель
Сommit
621df5577d
25 измененных файлов с 720 добавлено и 12 удалено
  1. 5 0
      packages/Webkul/Admin/src/Config/acl.php
  2. 6 0
      packages/Webkul/Admin/src/Config/menu.php
  3. 84 0
      packages/Webkul/Admin/src/DataGrids/Reporting/SearchLogDataGrid.php
  4. 54 0
      packages/Webkul/Admin/src/Http/Controllers/Reporting/SearchLogController.php
  5. 20 0
      packages/Webkul/Admin/src/Resources/lang/en/app.php
  6. 20 0
      packages/Webkul/Admin/src/Resources/lang/zh_CN/app.php
  7. 132 0
      packages/Webkul/Admin/src/Resources/views/reporting/search-logs/index.blade.php
  8. 7 0
      packages/Webkul/Admin/src/Routes/reporting-routes.php
  9. 62 0
      packages/Webkul/Admin/tests/Feature/Reporting/SearchLogReportTest.php
  10. 6 2
      packages/Webkul/BagistoApi/src/Http/Middleware/SearchMetadataResponse.php
  11. 2 1
      packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php
  12. 12 1
      packages/Webkul/BagistoApi/src/State/ProductGraphQLProvider.php
  13. 17 0
      packages/Webkul/BagistoApi/tests/Unit/Http/Middleware/SearchMetadataResponseTest.php
  14. 21 4
      packages/Webkul/BagistoApi/tests/Unit/Repositories/ElasticSearchRepositoryTest.php
  15. 92 0
      packages/Webkul/BagistoApi/tests/Unit/Services/SearchLogServiceTest.php
  16. 5 0
      packages/Webkul/Marketing/src/Contracts/SearchLog.php
  17. 30 0
      packages/Webkul/Marketing/src/Database/Migrations/2026_07_16_000000_create_search_log_table.php
  18. 25 0
      packages/Webkul/Marketing/src/Models/SearchLog.php
  19. 7 0
      packages/Webkul/Marketing/src/Models/SearchLogProxy.php
  20. 1 0
      packages/Webkul/Marketing/src/Providers/ModuleServiceProvider.php
  21. 13 0
      packages/Webkul/Marketing/src/Repositories/SearchLogRepository.php
  22. 47 0
      packages/Webkul/Marketing/src/Services/SearchLogService.php
  23. 13 1
      packages/Webkul/Product/src/Repositories/ElasticSearchRepository.php
  24. 15 3
      packages/Webkul/Shop/src/Http/Controllers/API/ProductController.php
  25. 24 0
      packages/Webkul/Shop/tests/Feature/API/ProductTest.php

+ 5 - 0
packages/Webkul/Admin/src/Config/acl.php

@@ -575,6 +575,11 @@ return [
         'name'  => 'admin::app.acl.products',
         'route' => 'admin.reporting.products.index',
         'sort'  => 3,
+    ], [
+        'key'   => 'reporting.search_logs',
+        'name'  => 'admin::app.acl.search-logs',
+        'route' => 'admin.reporting.search_logs.index',
+        'sort'  => 4,
     ],
 
     /*

+ 6 - 0
packages/Webkul/Admin/src/Config/menu.php

@@ -258,6 +258,12 @@ return [
         'route'      => 'admin.reporting.products.index',
         'sort'       => 3,
         'icon'       => '',
+    ], [
+        'key'        => 'reporting.search_logs',
+        'name'       => 'admin::app.components.layouts.sidebar.search-logs',
+        'route'      => 'admin.reporting.search_logs.index',
+        'sort'       => 4,
+        'icon'       => '',
     ],
 
     /**

+ 84 - 0
packages/Webkul/Admin/src/DataGrids/Reporting/SearchLogDataGrid.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace Webkul\Admin\DataGrids\Reporting;
+
+use Illuminate\Support\Facades\DB;
+use Webkul\DataGrid\DataGrid;
+
+class SearchLogDataGrid extends DataGrid
+{
+    public function prepareQueryBuilder()
+    {
+        $queryBuilder = DB::table('search_log')
+            ->select(
+                'id',
+                'search_text',
+                'query_text',
+                'results',
+                'ip_address',
+                'created_at'
+            )
+            ->orderByDesc('created_at');
+
+        foreach (['id', 'search_text', 'query_text', 'results', 'ip_address', 'created_at'] as $column) {
+            $this->addFilter($column, 'search_log.'.$column);
+        }
+
+        return $queryBuilder;
+    }
+
+    public function prepareColumns()
+    {
+        $this->addColumn([
+            'index'      => 'id',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.id'),
+            'type'       => 'integer',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'search_text',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.search-text'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'query_text',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.query-text'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'results',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.results'),
+            'type'       => 'integer',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'ip_address',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.ip-address'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'           => 'created_at',
+            'label'           => trans('admin::app.reporting.search-logs.datagrid.created-at'),
+            'type'            => 'date',
+            'filterable'      => true,
+            'filterable_type' => 'date_range',
+            'sortable'        => true,
+        ]);
+    }
+}

+ 54 - 0
packages/Webkul/Admin/src/Http/Controllers/Reporting/SearchLogController.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace Webkul\Admin\Http\Controllers\Reporting;
+
+use Carbon\Carbon;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\DB;
+use Webkul\Admin\DataGrids\Reporting\SearchLogDataGrid;
+use Webkul\Admin\Http\Controllers\Controller;
+
+class SearchLogController extends Controller
+{
+    public function index()
+    {
+        if (request()->ajax()) {
+            return datagrid(SearchLogDataGrid::class)->process();
+        }
+
+        return view('admin::reporting.search-logs.index', [
+            'startDate' => now()->subMonth()->startOfDay(),
+            'endDate'   => now()->endOfDay(),
+        ]);
+    }
+
+    public function stats(): JsonResponse
+    {
+        request()->validate([
+            'start' => ['nullable', 'date'],
+            'end'   => ['nullable', 'date', 'after_or_equal:start'],
+        ]);
+
+        $startDate = Carbon::parse(request()->query('start', now()->subMonth()))->startOfDay();
+        $endDate = Carbon::parse(request()->query('end', now()))->endOfDay();
+
+        $query = DB::table('search_log')
+            ->whereBetween('created_at', [$startDate, $endDate]);
+
+        $topQueries = (clone $query)
+            ->select('query_text', DB::raw('COUNT(*) as uses'))
+            ->groupBy('query_text')
+            ->orderByDesc('uses')
+            ->limit(5)
+            ->get();
+
+        return response()->json([
+            'statistics' => [
+                'total'      => (clone $query)->count(),
+                'zeroResult' => (clone $query)->where('results', 0)->count(),
+                'corrected'  => (clone $query)->whereColumn('query_text', '!=', 'search_text')->count(),
+                'topQueries' => $topQueries,
+            ],
+        ]);
+    }
+}

+ 20 - 0
packages/Webkul/Admin/src/Resources/lang/en/app.php

@@ -3602,6 +3602,24 @@ return [
     ],
 
     'reporting' => [
+        'search-logs' => [
+            'title'              => 'Search Logs',
+            'total-searches'     => 'Total Searches',
+            'zero-results'       => 'Zero Result Searches',
+            'corrected-searches' => 'Corrected or Relaxed Searches',
+            'top-queries'        => 'Top Effective Queries',
+            'no-records'         => 'No search logs found for this period.',
+
+            'datagrid' => [
+                'id'          => 'ID',
+                'search-text' => 'User Search Text',
+                'query-text'  => 'Effective Query',
+                'results'     => 'Results',
+                'ip-address'  => 'IP Address',
+                'created-at'  => 'Created At',
+            ],
+        ],
+
         'sales' => [
             'index' => [
                 'abandoned-carts'               => 'Abandoned Carts',
@@ -4741,6 +4759,7 @@ return [
                 'sales'                    => 'Sales',
                 'search-seo'               => 'Search & SEO',
                 'search-synonyms'          => 'Search Synonyms',
+                'search-logs'              => 'Search Logs',
                 'search-terms'             => 'Search Terms',
                 'settings'                 => 'Settings',
                 'shipments'                => 'Shipments',
@@ -5004,6 +5023,7 @@ return [
         'roles'                    => 'Roles',
         'sales'                    => 'Sales',
         'search-seo'               => 'Search & SEO',
+        'search-logs'              => 'Search Logs',
         'search-synonyms'          => 'Search Synonyms',
         'search-terms'             => 'Search Terms',
         'settings'                 => 'Settings',

+ 20 - 0
packages/Webkul/Admin/src/Resources/lang/zh_CN/app.php

@@ -3602,6 +3602,24 @@ return [
     ],
 
     'reporting' => [
+        'search-logs' => [
+            'title'              => '搜索日志',
+            'total-searches'     => '搜索总次数',
+            'zero-results'       => '零结果搜索',
+            'corrected-searches' => '纠错或降级搜索',
+            'top-queries'        => '热门实际查询词',
+            'no-records'         => '此时间段内没有搜索日志。',
+
+            'datagrid' => [
+                'id'          => 'ID',
+                'search-text' => '用户搜索词',
+                'query-text'  => '实际查询词',
+                'results'     => '结果数量',
+                'ip-address'  => 'IP 地址',
+                'created-at'  => '创建时间',
+            ],
+        ],
+
         'sales' => [
             'index' => [
                 'abandoned-carts'               => '被遗弃的购物车',
@@ -4741,6 +4759,7 @@ return [
                 'sales'                    => '销售',
                 'search-seo'               => '搜索与SEO',
                 'search-synonyms'          => '搜索同义词',
+                'search-logs'              => '搜索日志',
                 'search-terms'             => '搜索词',
                 'settings'                 => '设置',
                 'shipments'                => '发货',
@@ -5004,6 +5023,7 @@ return [
         'roles'                    => '角色',
         'sales'                    => '销售',
         'search-seo'               => '搜索和SEO',
+        'search-logs'              => '搜索日志',
         'search-synonyms'          => '搜索同义词',
         'search-terms'             => '搜索词',
         'settings'                 => '设置',

+ 132 - 0
packages/Webkul/Admin/src/Resources/views/reporting/search-logs/index.blade.php

@@ -0,0 +1,132 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('admin::app.reporting.search-logs.title')
+    </x-slot>
+
+    <div class="mb-5 flex items-center justify-between gap-4 max-sm:flex-wrap">
+        <p class="text-xl font-bold text-gray-800 dark:text-white">
+            @lang('admin::app.reporting.search-logs.title')
+        </p>
+    </div>
+
+    <v-search-log-report>
+        <div class="grid gap-4">
+            <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
+                @foreach (range(1, 3) as $index)
+                    <div class="shimmer h-24 rounded-lg"></div>
+                @endforeach
+            </div>
+
+            <x-admin::shimmer.datagrid />
+        </div>
+    </v-search-log-report>
+
+    @pushOnce('scripts')
+        <script
+            type="text/x-template"
+            id="v-search-log-report-template"
+        >
+            <div class="grid gap-4">
+                <div class="flex justify-end gap-2 max-sm:flex-wrap">
+                    <input
+                        type="date"
+                        class="rounded-md border px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-900"
+                        v-model="filters.start"
+                        @change="getStats"
+                    >
+
+                    <input
+                        type="date"
+                        class="rounded-md border px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-900"
+                        v-model="filters.end"
+                        @change="getStats"
+                    >
+                </div>
+
+                <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
+                    <div class="rounded-lg border bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
+                        <p class="text-sm text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.reporting.search-logs.total-searches')
+                        </p>
+                        <p class="mt-2 text-3xl font-bold">@{{ statistics.total }}</p>
+                    </div>
+
+                    <div class="rounded-lg border bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
+                        <p class="text-sm text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.reporting.search-logs.zero-results')
+                        </p>
+                        <p class="mt-2 text-3xl font-bold">@{{ statistics.zeroResult }}</p>
+                    </div>
+
+                    <div class="rounded-lg border bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
+                        <p class="text-sm text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.reporting.search-logs.corrected-searches')
+                        </p>
+                        <p class="mt-2 text-3xl font-bold">@{{ statistics.corrected }}</p>
+                    </div>
+                </div>
+
+                <div class="rounded-lg border bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
+                    <p class="mb-3 font-semibold">
+                        @lang('admin::app.reporting.search-logs.top-queries')
+                    </p>
+
+                    <div
+                        v-if="statistics.topQueries.length"
+                        class="grid gap-2"
+                    >
+                        <div
+                            v-for="query in statistics.topQueries"
+                            class="flex justify-between border-b pb-2 last:border-0 dark:border-gray-800"
+                        >
+                            <span>@{{ query.query_text }}</span>
+                            <span>@{{ query.uses }}</span>
+                        </div>
+                    </div>
+
+                    <p v-else class="text-sm text-gray-500">
+                        @lang('admin::app.reporting.search-logs.no-records')
+                    </p>
+                </div>
+
+                <x-admin::datagrid :src="route('admin.reporting.search_logs.index')" />
+            </div>
+        </script>
+
+        <script type="module">
+            app.component('v-search-log-report', {
+                template: '#v-search-log-report-template',
+
+                data() {
+                    return {
+                        filters: {
+                            start: "{{ $startDate->format('Y-m-d') }}",
+                            end: "{{ $endDate->format('Y-m-d') }}",
+                        },
+
+                        statistics: {
+                            total: 0,
+                            zeroResult: 0,
+                            corrected: 0,
+                            topQueries: [],
+                        },
+                    };
+                },
+
+                mounted() {
+                    this.getStats();
+                },
+
+                methods: {
+                    getStats() {
+                        this.$axios.get("{{ route('admin.reporting.search_logs.stats') }}", {
+                            params: this.filters,
+                        }).then(({ data }) => {
+                            this.statistics = data.statistics;
+                        });
+                    },
+                },
+            });
+        </script>
+    @endPushOnce
+</x-admin::layouts>

+ 7 - 0
packages/Webkul/Admin/src/Routes/reporting-routes.php

@@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route;
 use Webkul\Admin\Http\Controllers\Reporting\CustomerController;
 use Webkul\Admin\Http\Controllers\Reporting\ProductController;
 use Webkul\Admin\Http\Controllers\Reporting\SaleController;
+use Webkul\Admin\Http\Controllers\Reporting\SearchLogController;
 
 /**
  * Reporting routes.
@@ -53,4 +54,10 @@ Route::prefix('reporting')->group(function () {
 
         Route::get('view/stats', 'viewStats')->name('admin.reporting.sales.view.stats');
     });
+
+    Route::controller(SearchLogController::class)->prefix('search-logs')->group(function () {
+        Route::get('', 'index')->name('admin.reporting.search_logs.index');
+
+        Route::get('stats', 'stats')->name('admin.reporting.search_logs.stats');
+    });
 });

+ 62 - 0
packages/Webkul/Admin/tests/Feature/Reporting/SearchLogReportTest.php

@@ -0,0 +1,62 @@
+<?php
+
+use Webkul\Marketing\Models\SearchLog;
+
+use function Pest\Laravel\get;
+use function Pest\Laravel\getJson;
+
+it('shows the search log reporting page', function () {
+    $this->loginAsAdmin();
+
+    get(route('admin.reporting.search_logs.index'))
+        ->assertOk()
+        ->assertSeeText(trans('admin::app.reporting.search-logs.title'))
+        ->assertSeeText(trans('admin::app.reporting.search-logs.total-searches'))
+        ->assertSeeText(trans('admin::app.reporting.search-logs.zero-results'));
+});
+
+it('returns search log summary statistics for the requested period', function () {
+    $this->loginAsAdmin();
+
+    SearchLog::query()->create([
+        'search_text' => 'readyy',
+        'query_text'  => 'ready',
+        'results'     => 10,
+        'ip_address'  => '203.0.113.10',
+    ]);
+
+    SearchLog::query()->create([
+        'search_text' => 'missing',
+        'query_text'  => 'missing',
+        'results'     => 0,
+        'ip_address'  => '203.0.113.11',
+    ]);
+
+    get(route('admin.reporting.search_logs.stats', [
+        'start' => now()->subDay()->format('Y-m-d'),
+        'end'   => now()->addDay()->format('Y-m-d'),
+    ]))
+        ->assertOk()
+        ->assertJsonPath('statistics.total', 2)
+        ->assertJsonPath('statistics.zeroResult', 1)
+        ->assertJsonPath('statistics.corrected', 1);
+});
+
+it('returns searchable search log datagrid records', function () {
+    $this->loginAsAdmin();
+
+    $log = SearchLog::query()->create([
+        'search_text' => 'readyy',
+        'query_text'  => 'ready',
+        'results'     => 10,
+        'ip_address'  => '203.0.113.10',
+    ]);
+
+    getJson(route('admin.reporting.search_logs.index'), [
+        'X-Requested-With' => 'XMLHttpRequest',
+    ])
+        ->assertOk()
+        ->assertJsonPath('records.0.id', $log->id)
+        ->assertJsonPath('records.0.search_text', 'readyy')
+        ->assertJsonPath('records.0.query_text', 'ready');
+});

+ 6 - 2
packages/Webkul/BagistoApi/src/Http/Middleware/SearchMetadataResponse.php

@@ -3,8 +3,8 @@
 namespace Webkul\BagistoApi\Http\Middleware;
 
 use Closure;
-use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Response;
 
 /**
@@ -21,7 +21,11 @@ class SearchMetadataResponse
             return $response;
         }
 
-        $payload = $response->getData(true);
+        $payload = json_decode((string) $response->getContent(), true);
+
+        if (! is_array($payload)) {
+            return $response;
+        }
 
         if ($this->isGraphQlRequest($request)) {
             $payload['extensions']['searchMetadata'] = $metadata;

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

@@ -460,7 +460,8 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->singleton(ProductGraphQLProvider::class, function ($app) {
             return new ProductGraphQLProvider(
                 $app->make(Pagination::class),
-                $app->make(\Webkul\Product\Repositories\ElasticSearchRepository::class)
+                $app->make(\Webkul\Product\Repositories\ElasticSearchRepository::class),
+                $app->make(\Webkul\Marketing\Services\SearchLogService::class)
             );
         });
 

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

@@ -8,13 +8,15 @@ use ApiPlatform\State\Pagination\Pagination;
 use ApiPlatform\State\ProviderInterface;
 use Illuminate\Pagination\LengthAwarePaginator as LaravelPaginator;
 use Webkul\BagistoApi\Models\Product;
+use Webkul\Marketing\Services\SearchLogService;
 use Webkul\Product\Repositories\ElasticSearchRepository;
 
 class ProductGraphQLProvider implements ProviderInterface
 {
     public function __construct(
         private readonly Pagination $pagination,
-        private readonly ElasticSearchRepository $elasticSearchRepository
+        private readonly ElasticSearchRepository $elasticSearchRepository,
+        private readonly SearchLogService $searchLogService
     ) {}
 
     private ?array $attributeTypeCache = null;
@@ -51,6 +53,7 @@ class ProductGraphQLProvider implements ProviderInterface
 
         $elasticSearchIds = null;
         $suggest = filter_var($args['suggest'] ?? false, FILTER_VALIDATE_BOOLEAN);
+        $effectiveQuery = $args['query'] ?? '';
 
         if (
             ! empty($args['query'])
@@ -59,6 +62,7 @@ class ProductGraphQLProvider implements ProviderInterface
         ) {
             $searchResult = $this->elasticSearchRepository->searchForApi($args['query'], $suggest);
             $elasticSearchIds = $searchResult['ids'];
+            $effectiveQuery = $searchResult['query'];
 
             if ($searchResult['query'] !== $args['query']) {
                 request()->attributes->set('bagistoapi.search_metadata', [
@@ -329,6 +333,13 @@ class ProductGraphQLProvider implements ProviderInterface
             ->distinct('products.id')
             ->count('products.id');
 
+        $this->searchLogService->recordWhenSuggested(
+            $args['suggest'] ?? false,
+            $args['query'] ?? '',
+            $effectiveQuery,
+            $total
+        );
+
         if ($offset > $total) {
             $offset = max(0, $total - $limit);
         }

+ 17 - 0
packages/Webkul/BagistoApi/tests/Unit/Http/Middleware/SearchMetadataResponseTest.php

@@ -37,6 +37,23 @@ class SearchMetadataResponseTest extends TestCase
         $this->assertSame($this->metadata(), $response->getData(true)['extensions']['searchMetadata']);
     }
 
+    public function test_it_supports_api_platform_symfony_json_responses(): void
+    {
+        $request = Request::create('/api/graphql', 'POST');
+        $request->attributes->set('bagistoapi.search_metadata', $this->metadata());
+
+        $response = (new SearchMetadataResponse())->handle(
+            $request,
+            fn () => new \Symfony\Component\HttpFoundation\JsonResponse([
+                'data' => ['products' => []],
+            ])
+        );
+
+        $payload = json_decode((string) $response->getContent(), true);
+
+        $this->assertSame($this->metadata(), $payload['extensions']['searchMetadata']);
+    }
+
     private function metadata(): array
     {
         return [

+ 21 - 4
packages/Webkul/BagistoApi/tests/Unit/Repositories/ElasticSearchRepositoryTest.php

@@ -14,10 +14,11 @@ class ElasticSearchRepositoryTest extends TestCase
         $repository = app(ElasticSearchRepository::class);
 
         ElasticSearch::shouldReceive('search')
-            ->times(3)
+            ->times(4)
             ->andReturn(
                 $this->emptySearchResponse(),
                 ['suggest' => ['name_suggest' => []]],
+                $this->emptySearchResponse(),
                 $this->searchResponse([42])
             );
 
@@ -37,10 +38,26 @@ class ElasticSearchRepositoryTest extends TestCase
             ->with(Mockery::on(function (array $request) {
                 $should = $request['body']['query']['bool']['should'];
 
-                return $should[0]['multi_match']['fields'] === ['name^4', 'sku^6']
-                    && $should[0]['multi_match']['operator'] === 'and'
-                    && $should[1]['multi_match']['fuzziness'] === 'AUTO';
+                return count($should) === 1
+                    && $should[0]['multi_match']['fields'] === ['name^4', 'sku^6']
+                    && $should[0]['multi_match']['operator'] === 'and';
+            }))
+            ->ordered()
+            ->andReturn($this->emptySearchResponse());
+
+        ElasticSearch::shouldReceive('search')
+            ->once()
+            ->ordered()
+            ->andReturn(['suggest' => ['name_suggest' => []]]);
+
+        ElasticSearch::shouldReceive('search')
+            ->once()
+            ->with(Mockery::on(function (array $request) {
+                $should = $request['body']['query']['bool']['should'];
+
+                return $should[1]['multi_match']['fuzziness'] === 'AUTO';
             }))
+            ->ordered()
             ->andReturn($this->searchResponse([7]));
 
         $result = $repository->searchForApi('purpel wave', true);

+ 92 - 0
packages/Webkul/BagistoApi/tests/Unit/Services/SearchLogServiceTest.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Services;
+
+use Illuminate\Http\Request;
+use Mockery;
+use Tests\TestCase;
+use Webkul\Marketing\Contracts\SearchLog;
+use Webkul\Marketing\Repositories\SearchLogRepository;
+use Webkul\Marketing\Services\SearchLogService;
+
+class SearchLogServiceTest extends TestCase
+{
+    public function test_it_records_normalized_search_details_and_request_ip(): void
+    {
+        $repository = Mockery::mock(SearchLogRepository::class);
+        $searchLog = Mockery::mock(SearchLog::class);
+        $request = Request::create(
+            '/api/shop/products?query=readyy&suggest=1',
+            'GET',
+            server: ['REMOTE_ADDR' => '203.0.113.10']
+        );
+
+        $repository->shouldReceive('create')
+            ->once()
+            ->with([
+                'search_text' => 'readyy',
+                'query_text'  => 'ready',
+                'results'     => 12,
+                'ip_address'  => '203.0.113.10',
+            ])
+            ->andReturn($searchLog);
+
+        $result = (new SearchLogService($repository))->record(
+            ' readyy ',
+            ' ready ',
+            12,
+            $request
+        );
+
+        $this->assertSame($searchLog, $result);
+    }
+
+    public function test_it_clamps_negative_result_counts_to_zero(): void
+    {
+        $repository = Mockery::mock(SearchLogRepository::class);
+        $searchLog = Mockery::mock(SearchLog::class);
+
+        $repository->shouldReceive('create')
+            ->once()
+            ->with(Mockery::on(fn (array $data) => $data['results'] === 0))
+            ->andReturn($searchLog);
+
+        (new SearchLogService($repository))->record(
+            'missing',
+            'missing',
+            -1,
+            Request::create('/')
+        );
+    }
+
+    public function test_it_only_records_when_suggest_is_enabled(): void
+    {
+        $repository = Mockery::mock(SearchLogRepository::class);
+        $searchLog = Mockery::mock(SearchLog::class);
+        $service = new SearchLogService($repository);
+
+        $repository->shouldNotReceive('create');
+
+        $this->assertNull($service->recordWhenSuggested(
+            false,
+            'readyy',
+            'ready',
+            12,
+            Request::create('/')
+        ));
+
+        $repository = Mockery::mock(SearchLogRepository::class);
+        $repository->shouldReceive('create')->once()->andReturn($searchLog);
+
+        $this->assertSame(
+            $searchLog,
+            (new SearchLogService($repository))->recordWhenSuggested(
+                '1',
+                'readyy',
+                'ready',
+                12,
+                Request::create('/')
+            )
+        );
+    }
+}

+ 5 - 0
packages/Webkul/Marketing/src/Contracts/SearchLog.php

@@ -0,0 +1,5 @@
+<?php
+
+namespace Webkul\Marketing\Contracts;
+
+interface SearchLog {}

+ 30 - 0
packages/Webkul/Marketing/src/Database/Migrations/2026_07_16_000000_create_search_log_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('search_log', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->string('query_text', 512);
+            $table->string('search_text', 512);
+            $table->unsignedInteger('results')->default(0);
+            $table->string('ip_address', 45)->nullable();
+            $table->timestamp('created_at')->useCurrent();
+
+            $table->index('query_text');
+            $table->index('search_text');
+            $table->index('results');
+            $table->index('created_at');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('search_log');
+    }
+};

+ 25 - 0
packages/Webkul/Marketing/src/Models/SearchLog.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace Webkul\Marketing\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Webkul\Marketing\Contracts\SearchLog as SearchLogContract;
+
+class SearchLog extends Model implements SearchLogContract
+{
+    public const UPDATED_AT = null;
+
+    protected $table = 'search_log';
+
+    protected $fillable = [
+        'query_text',
+        'search_text',
+        'results',
+        'ip_address',
+    ];
+
+    protected $casts = [
+        'results'    => 'integer',
+        'created_at' => 'datetime',
+    ];
+}

+ 7 - 0
packages/Webkul/Marketing/src/Models/SearchLogProxy.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Webkul\Marketing\Models;
+
+use Konekt\Concord\Proxies\ModelProxy;
+
+class SearchLogProxy extends ModelProxy {}

+ 1 - 0
packages/Webkul/Marketing/src/Providers/ModuleServiceProvider.php

@@ -14,6 +14,7 @@ class ModuleServiceProvider extends CoreModuleServiceProvider
     protected $models = [
         \Webkul\Marketing\Models\Campaign::class,
         \Webkul\Marketing\Models\Event::class,
+        \Webkul\Marketing\Models\SearchLog::class,
         \Webkul\Marketing\Models\SearchSynonym::class,
         \Webkul\Marketing\Models\SearchTerm::class,
         \Webkul\Marketing\Models\Template::class,

+ 13 - 0
packages/Webkul/Marketing/src/Repositories/SearchLogRepository.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace Webkul\Marketing\Repositories;
+
+use Webkul\Core\Eloquent\Repository;
+
+class SearchLogRepository extends Repository
+{
+    public function model(): string
+    {
+        return 'Webkul\Marketing\Contracts\SearchLog';
+    }
+}

+ 47 - 0
packages/Webkul/Marketing/src/Services/SearchLogService.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace Webkul\Marketing\Services;
+
+use Illuminate\Http\Request;
+use Webkul\Marketing\Contracts\SearchLog;
+use Webkul\Marketing\Repositories\SearchLogRepository;
+
+class SearchLogService
+{
+    public function __construct(
+        protected SearchLogRepository $searchLogRepository
+    ) {}
+
+    public function recordWhenSuggested(
+        mixed $suggest,
+        string $searchText,
+        string $queryText,
+        int $results,
+        ?Request $request = null
+    ): ?SearchLog {
+        if (
+            ! filter_var($suggest, FILTER_VALIDATE_BOOLEAN)
+            || trim($searchText) === ''
+        ) {
+            return null;
+        }
+
+        return $this->record($searchText, $queryText, $results, $request);
+    }
+
+    public function record(
+        string $searchText,
+        string $queryText,
+        int $results,
+        ?Request $request = null
+    ): SearchLog {
+        $request ??= request();
+
+        return $this->searchLogRepository->create([
+            'search_text' => mb_substr(trim($searchText), 0, 512),
+            'query_text'  => mb_substr(trim($queryText), 0, 512),
+            'results'     => max(0, $results),
+            'ip_address'  => $request->ip(),
+        ]);
+    }
+}

+ 13 - 1
packages/Webkul/Product/src/Repositories/ElasticSearchRepository.php

@@ -103,7 +103,7 @@ class ElasticSearchRepository
      */
     public function searchForApi(string $queryText, bool $suggest = false): array
     {
-        $result = $this->searchApiQuery($queryText, $suggest);
+        $result = $this->searchApiQuery($queryText, false);
 
         if ($result['total'] > 0 || ! $suggest) {
             return $result;
@@ -112,6 +112,12 @@ class ElasticSearchRepository
         $correctedQuery = $this->getCorrectedQuery($queryText);
 
         if ($correctedQuery && $correctedQuery !== $queryText) {
+            $result = $this->searchApiQuery($correctedQuery, false);
+
+            if ($result['total'] > 0) {
+                return $result;
+            }
+
             $result = $this->searchApiQuery($correctedQuery, true);
 
             if ($result['total'] > 0) {
@@ -119,6 +125,12 @@ class ElasticSearchRepository
             }
         }
 
+        $result = $this->searchApiQuery($queryText, true);
+
+        if ($result['total'] > 0) {
+            return $result;
+        }
+
         foreach ($this->getFallbackQueries($correctedQuery ?: $queryText) as $fallbackQuery) {
             $result = $this->searchApiQuery($fallbackQuery, true);
 

+ 15 - 3
packages/Webkul/Shop/src/Http/Controllers/API/ProductController.php

@@ -5,6 +5,7 @@ namespace Webkul\Shop\Http\Controllers\API;
 use Illuminate\Http\Resources\Json\JsonResource;
 use Webkul\Category\Repositories\CategoryRepository;
 use Webkul\Marketing\Jobs\UpdateCreateSearchTerm as UpdateCreateSearchTermJob;
+use Webkul\Marketing\Services\SearchLogService;
 use Webkul\Product\Repositories\ProductRepository;
 use Webkul\Shop\Http\Resources\ProductResource;
 
@@ -17,7 +18,8 @@ class ProductController extends APIController
      */
     public function __construct(
         protected CategoryRepository $categoryRepository,
-        protected ProductRepository $productRepository
+        protected ProductRepository $productRepository,
+        protected SearchLogService $searchLogService
     ) {}
 
     /**
@@ -44,12 +46,19 @@ class ProductController extends APIController
                 'visible_individually' => 1,
             ]));
 
+        $this->searchLogService->recordWhenSuggested(
+            request()->query('suggest'),
+            $searchData['original_query'],
+            $query,
+            $products->total()
+        );
+
         if (! empty($query)) {
             /**
              * Update or create search term only if
              * there is only one filter that is query param
              */
-            if (count(request()->except(['mode', 'sort', 'limit'])) == 1) {
+            if (count(request()->except(['mode', 'sort', 'limit', 'suggest'])) == 1) {
                 UpdateCreateSearchTermJob::dispatch([
                     'term'       => $query,
                     'results'    => $products->total(),
@@ -67,7 +76,10 @@ class ProductController extends APIController
      */
     protected function resolveSearchQueryData($searchEngine): array
     {
-        if (request()->query('suggest', '') === '0') {
+        if (
+            ! request()->has('suggest')
+            || request()->query('suggest') !== '0'
+        ) {
             return [
                 'original_query'  => request()->query('query', ''),
                 'effective_query' => null,

+ 24 - 0
packages/Webkul/Shop/tests/Feature/API/ProductTest.php

@@ -2,6 +2,7 @@
 
 use Pest\Expectation;
 use Webkul\Faker\Helpers\Product as ProductFaker;
+use Webkul\Marketing\Models\SearchLog;
 
 use function Pest\Laravel\getJson;
 
@@ -77,3 +78,26 @@ it('returns all products listing', function () {
             'id' => $product->id,
         ]);
 });
+
+it('records product searches only when suggest is enabled', function () {
+    $searchText = 'missing-product-'.uniqid();
+
+    getJson(route('shop.api.products.index', [
+        'query' => $searchText,
+    ]))->assertOk();
+
+    expect(SearchLog::query()->where('search_text', $searchText)->count())->toBe(0);
+
+    getJson(route('shop.api.products.index', [
+        'query'   => $searchText,
+        'suggest' => 1,
+    ]))->assertOk();
+
+    $log = SearchLog::query()->where('search_text', $searchText)->first();
+
+    expect($log)
+        ->not->toBeNull()
+        ->query_text->toBe($searchText)
+        ->results->toBe(0)
+        ->ip_address->not->toBeNull();
+});