bianjunhui 3 месяцев назад
Родитель
Сommit
2988c17cdd
19 измененных файлов с 456 добавлено и 11 удалено
  1. 25 5
      packages/Webkul/Admin/src/DataGrids/Marketing/Promotions/CartRuleCouponDataGrid.php
  2. 23 2
      packages/Webkul/Admin/src/Http/Controllers/Marketing/Promotions/CartRuleController.php
  3. 11 2
      packages/Webkul/Admin/src/Http/Controllers/Marketing/Promotions/CartRuleCouponController.php
  4. 1 0
      packages/Webkul/Admin/src/Http/Requests/CartRuleRequest.php
  5. 5 0
      packages/Webkul/Admin/src/Resources/lang/en/app.php
  6. 5 0
      packages/Webkul/Admin/src/Resources/lang/zh_CN/app.php
  7. 24 0
      packages/Webkul/Admin/src/Resources/views/marketing/promotions/cart-rules/create.blade.php
  8. 44 1
      packages/Webkul/Admin/src/Resources/views/marketing/promotions/cart-rules/edit.blade.php
  9. 24 0
      packages/Webkul/CartRule/src/Database/Migrations/2025_06_26_100000_add_customer_id_to_cart_rule_coupons.php
  10. 23 0
      packages/Webkul/CartRule/src/Database/Migrations/2025_06_26_150000_add_unique_code_to_cart_rule_coupons.php
  11. 7 0
      packages/Webkul/CartRule/src/Helpers/CartRule.php
  12. 10 1
      packages/Webkul/CartRule/src/Models/CartRuleCoupon.php
  13. 1 0
      packages/Webkul/CartRule/src/Repositories/CartRuleCouponRepository.php
  14. 3 0
      packages/Webkul/CartRule/src/Repositories/CartRuleRepository.php
  15. 11 0
      packages/Webkul/Shop/src/Http/Controllers/API/CartController.php
  16. 203 0
      packages/Webkul/Shop/src/Http/Controllers/API/CouponController.php
  17. 13 0
      packages/Webkul/Shop/src/Resources/lang/en/app.php
  18. 13 0
      packages/Webkul/Shop/src/Resources/lang/zh_CN/app.php
  19. 10 0
      packages/Webkul/Shop/src/Routes/api-token.php

+ 25 - 5
packages/Webkul/Admin/src/DataGrids/Marketing/Promotions/CartRuleCouponDataGrid.php

@@ -15,12 +15,16 @@ class CartRuleCouponDataGrid extends DataGrid
     public function prepareQueryBuilder()
     {
         $queryBuilder = DB::table('cart_rule_coupons')
+            ->leftJoin('customers', 'cart_rule_coupons.customer_id', '=', 'customers.id')
             ->select(
-                'id',
-                'code',
-                'created_at',
-                'expired_at',
-                'times_used'
+                'cart_rule_coupons.id',
+                'cart_rule_coupons.code',
+                'cart_rule_coupons.created_at',
+                'cart_rule_coupons.expired_at',
+                'cart_rule_coupons.times_used',
+                'customers.email as customer_email',
+                'customers.first_name',
+                'customers.last_name'
             )
             ->where('cart_rule_coupons.cart_rule_id', request('id'));
 
@@ -77,6 +81,22 @@ class CartRuleCouponDataGrid extends DataGrid
             'filterable' => true,
             'sortable'   => true,
         ]);
+
+        $this->addColumn([
+            'index'      => 'customer_email',
+            'label'      => trans('admin::app.marketing.promotions.cart-rules-coupons.datagrid.customer'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+            'closure'    => function ($row) {
+                if ($row->customer_email) {
+                    return $row->first_name.' '.$row->last_name.' ('.$row->customer_email.')';
+                }
+
+                return '-';
+            },
+        ]);
     }
 
     /**

+ 23 - 2
packages/Webkul/Admin/src/Http/Controllers/Marketing/Promotions/CartRuleController.php

@@ -82,9 +82,11 @@ class CartRuleController extends Controller
     public function store(CartRuleRequest $cartRuleRequest)
     {
         try {
+            $data = $this->resolveCustomerId($cartRuleRequest->all());
+
             Event::dispatch('promotions.cart_rule.create.before');
 
-            $cartRule = $this->cartRuleRepository->create($cartRuleRequest->all());
+            $cartRule = $this->cartRuleRepository->create($data);
 
             Event::dispatch('promotions.cart_rule.create.after', $cartRule);
 
@@ -120,6 +122,8 @@ class CartRuleController extends Controller
     public function update(CartRuleRequest $cartRuleRequest, int $id)
     {
         try {
+            $data = $this->resolveCustomerId($cartRuleRequest->all());
+
             $cartRule = $this->cartRuleRepository->findOrFail($id);
 
             if ($cartRule->coupon_type) {
@@ -136,7 +140,7 @@ class CartRuleController extends Controller
 
             Event::dispatch('promotions.cart_rule.update.before', $id);
 
-            $cartRule = $this->cartRuleRepository->update($cartRuleRequest->all(), $id);
+            $cartRule = $this->cartRuleRepository->update($data, $id);
 
             Event::dispatch('promotions.cart_rule.update.after', $cartRule);
 
@@ -152,6 +156,23 @@ class CartRuleController extends Controller
         return redirect()->back();
     }
 
+    /**
+     * Resolve customer email to customer_id.
+     */
+    private function resolveCustomerId(array $data): array
+    {
+        if (! empty($data['specific_customer_email'])) {
+            $customer = app('Webkul\Customer\Repositories\CustomerRepository')
+                ->findOneByField('email', $data['specific_customer_email']);
+            $data['customer_id'] = $customer ? $customer->id : null;
+        } else {
+            $data['customer_id'] = null;
+        }
+        unset($data['specific_customer_email']);
+
+        return $data;
+    }
+
     /**
      * Remove the specified resource from storage.
      */

+ 11 - 2
packages/Webkul/Admin/src/Http/Controllers/Marketing/Promotions/CartRuleCouponController.php

@@ -47,13 +47,22 @@ class CartRuleCouponController extends Controller
             ], 400);
         }
 
-        $this->cartRuleCouponRepository->generateCoupons(request()->only(
+        $data = request()->only(
             'coupon_qty',
             'code_length',
             'code_format',
             'code_prefix',
             'code_suffix'
-        ), $id);
+        );
+
+        // Resolve customer email to customer_id
+        if (request()->filled('specific_customer_email')) {
+            $customer = app('Webkul\Customer\Repositories\CustomerRepository')
+                ->findOneByField('email', request()->input('specific_customer_email'));
+            $data['customer_id'] = $customer ? $customer->id : null;
+        }
+
+        $this->cartRuleCouponRepository->generateCoupons($data, $id);
 
         return new JsonResponse([
             'message' => trans(

+ 1 - 0
packages/Webkul/Admin/src/Http/Requests/CartRuleRequest.php

@@ -33,6 +33,7 @@ class CartRuleRequest extends FormRequest
             'ends_till'           => 'nullable|date|after_or_equal:starts_from',
             'action_type'         => 'required',
             'discount_amount'     => 'required|numeric',
+            'customer_id'         => 'nullable|integer|exists:customers,id',
         ];
 
         if (! request('id')) {

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

@@ -2335,6 +2335,8 @@ return [
                     'uses-per-coupon'                           => 'Uses Per Coupon',
                     'uses-per-customer'                         => 'Uses Per Customer',
                     'uses-per-customer-control-info'            => 'Will be used for logged in customers only.',
+                    'specific-customer'                         => 'Specific Customer',
+                    'specific-customer-placeholder'             => 'Enter customer email to assign',
                     'yes'                                       => 'Yes',
                 ],
 
@@ -2420,6 +2422,8 @@ return [
                     'uses-per-coupon'                           => 'Uses Per Coupon',
                     'uses-per-customer'                         => 'Uses Per Customer',
                     'uses-per-customer-control-info'            => 'Will be used for logged in customers only.',
+                    'specific-customer'                         => 'Specific Customer',
+                    'specific-customer-placeholder'             => 'Enter customer email to assign',
                     'yes'                                       => 'Yes',
                 ],
 
@@ -2545,6 +2549,7 @@ return [
                 'datagrid' => [
                     'coupon-code'     => 'Coupon Code',
                     'created-date'    => 'Created Date',
+                    'customer'        => 'Customer',
                     'delete'          => 'Delete',
                     'expiration-date' => 'Expiration Date',
                     'id'              => 'ID',

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

@@ -2335,6 +2335,8 @@ return [
                     'uses-per-coupon'                           => '每张优惠券使用次数',
                     'uses-per-customer'                         => '每位顾客使用次数',
                     'uses-per-customer-control-info'            => '仅适用于已登录的顾客。',
+                    'specific-customer'                         => '指定专属顾客',
+                    'specific-customer-placeholder'             => '输入顾客邮箱进行指定',
                     'yes'                                       => '是',
                 ],
 
@@ -2420,6 +2422,8 @@ return [
                     'uses-per-coupon'                           => '每张优惠券使用次数',
                     'uses-per-customer'                         => '每位顾客使用次数',
                     'uses-per-customer-control-info'            => '仅适用于已登录的顾客。',
+                    'specific-customer'                         => '指定专属顾客',
+                    'specific-customer-placeholder'             => '输入顾客邮箱进行指定',
                     'yes'                                       => '是',
                 ],
 
@@ -2545,6 +2549,7 @@ return [
                 'datagrid' => [
                     'coupon-code'     => '优惠券代码',
                     'created-date'    => '创建日期',
+                    'customer'        => '指定顾客',
                     'delete'          => '删除',
                     'expiration-date' => '到期日期',
                     'id'              => 'ID',

+ 24 - 0
packages/Webkul/Admin/src/Resources/views/marketing/promotions/cart-rules/create.blade.php

@@ -195,6 +195,26 @@
 
                                 <x-admin::form.control-group.error control-name="uses_per_coupon" />
                             </x-admin::form.control-group>
+
+                            <!-- Specific Customer -->
+                            <x-admin::form.control-group>
+                                <x-admin::form.control-group.label>
+                                    @lang('admin::app.marketing.promotions.cart-rules.create.specific-customer')
+                                </x-admin::form.control-group.label>
+
+                                <x-admin::form.control-group.control
+                                    type="text"
+                                    id="specific_customer_email"
+                                    name="specific_customer_email"
+                                    :label="trans('admin::app.marketing.promotions.cart-rules.create.specific-customer')"
+                                    :placeholder="trans('admin::app.marketing.promotions.cart-rules.create.specific-customer-placeholder')"
+                                    v-model="specificCustomerEmail"
+                                />
+
+                                <input type="hidden" name="customer_id" :value="customerId" />
+
+                                <x-admin::form.control-group.error control-name="customer_id" />
+                            </x-admin::form.control-group>
                         </template>
 
                         <!-- Uses Per Customer -->
@@ -688,6 +708,10 @@
                         conditions: [],
 
                         actionType: "{{ old('action_type') ?: 'by_percent' }}",
+
+                        specificCustomerEmail: "{{ old('specific_customer_email', '') }}",
+
+                        customerId: "{{ old('customer_id', '') }}",
                     }
                 },
 

+ 44 - 1
packages/Webkul/Admin/src/Resources/views/marketing/promotions/cart-rules/edit.blade.php

@@ -213,6 +213,27 @@
 
                                 <x-admin::form.control-group.error control-name="uses_per_coupon" />
                             </x-admin::form.control-group>
+
+                            <!-- Specific Customer -->
+                            <x-admin::form.control-group>
+                                <x-admin::form.control-group.label>
+                                    @lang('admin::app.marketing.promotions.cart-rules.edit.specific-customer')
+                                </x-admin::form.control-group.label>
+
+                                <x-admin::form.control-group.control
+                                    type="text"
+                                    id="specific_customer_email"
+                                    name="specific_customer_email"
+                                    :value="old('specific_customer_email') ?? $cartRule->cart_rule_coupon?->customer?->email"
+                                    :label="trans('admin::app.marketing.promotions.cart-rules.edit.specific-customer')"
+                                    :placeholder="trans('admin::app.marketing.promotions.cart-rules.edit.specific-customer-placeholder')"
+                                    v-model="specificCustomerEmail"
+                                />
+
+                                <input type="hidden" name="customer_id" :value="customerId" />
+
+                                <x-admin::form.control-group.error control-name="customer_id" />
+                            </x-admin::form.control-group>
                         </template>
 
                         <!-- Usage Per Customer -->
@@ -738,6 +759,10 @@
                         conditions: @json($cartRule->conditions ?? []),
 
                         actionType: "{{ old('action_type') ?? $cartRule->action_type }}",
+
+                        specificCustomerEmail: "{{ old('specific_customer_email') ?? $cartRule->cart_rule_coupon?->customer?->email }}",
+
+                        customerId: "{{ old('customer_id') ?? $cartRule->cart_rule_coupon?->customer_id }}",
                     }
                 },
 
@@ -1238,6 +1263,22 @@
                                 </div>
                             </div>
 
+                            <!-- Specific Customer Email -->
+                            <x-admin::form.control-group>
+                                <x-admin::form.control-group.label>
+                                    @lang('admin::app.marketing.promotions.cart-rules.edit.specific-customer')
+                                </x-admin::form.control-group.label>
+
+                                <x-admin::form.control-group.control
+                                    type="text"
+                                    id="specific_customer_email"
+                                    name="specific_customer_email"
+                                    v-model="coupon_format.specific_customer_email"
+                                    :label="trans('admin::app.marketing.promotions.cart-rules.edit.specific-customer')"
+                                    :placeholder="trans('admin::app.marketing.promotions.cart-rules.edit.specific-customer-placeholder')"
+                                />
+                            </x-admin::form.control-group>
+
                             <div class="flex gap-4 max-sm:flex-wrap">
                                 <div class="mb-2.5 w-full">
                                     <!-- Code Format -->
@@ -1349,7 +1390,9 @@
 
                             code_prefix: '',
 
-                            code_suffix: ''
+                            code_suffix: '',
+
+                            specific_customer_email: '',
                         }
                     };
                 },

+ 24 - 0
packages/Webkul/CartRule/src/Database/Migrations/2025_06_26_100000_add_customer_id_to_cart_rule_coupons.php

@@ -0,0 +1,24 @@
+<?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::table('cart_rule_coupons', function (Blueprint $table) {
+            $table->integer('customer_id')->unsigned()->nullable()->after('expired_at');
+            $table->foreign('customer_id')->references('id')->on('customers')->onDelete('set null');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('cart_rule_coupons', function (Blueprint $table) {
+            $table->dropForeign(['customer_id']);
+            $table->dropColumn('customer_id');
+        });
+    }
+};

+ 23 - 0
packages/Webkul/CartRule/src/Database/Migrations/2025_06_26_150000_add_unique_code_to_cart_rule_coupons.php

@@ -0,0 +1,23 @@
+<?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
+    {
+        // MySQL: NULL 值在 UNIQUE 索引中允许重复,只有非 NULL 值必须唯一
+        Schema::table('cart_rule_coupons', function (Blueprint $table) {
+            $table->unique('code');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('cart_rule_coupons', function (Blueprint $table) {
+            $table->dropUnique(['code']);
+        });
+    }
+};

+ 7 - 0
packages/Webkul/CartRule/src/Helpers/CartRule.php

@@ -145,6 +145,13 @@ class CartRule
                     return false;
                 }
 
+                // Check if coupon is restricted to a specific customer
+                if ($coupon->customer_id) {
+                    if (! $this->cart->customer_id || $this->cart->customer_id != $coupon->customer_id) {
+                        return false;
+                    }
+                }
+
                 if (
                     $this->cart->customer_id
                     && $coupon->usage_per_customer

+ 10 - 1
packages/Webkul/CartRule/src/Models/CartRuleCoupon.php

@@ -22,6 +22,7 @@ class CartRuleCoupon extends Model implements CartRuleCouponContract
         'cart_rule_id',
         'expired_at',
         'is_primary',
+        'customer_id',
     ];
 
     /**
@@ -33,7 +34,15 @@ class CartRuleCoupon extends Model implements CartRuleCouponContract
     }
 
     /**
-     * Get the cart rule that owns the cart rule coupon.
+     * Get the customer assigned to this coupon.
+     */
+    public function customer(): BelongsTo
+    {
+        return $this->belongsTo(\Webkul\Customer\Models\CustomerProxy::modelClass());
+    }
+
+    /**
+     * Get the coupon usage records.
      */
     public function coupon_usage()
     {

+ 1 - 0
packages/Webkul/CartRule/src/Repositories/CartRuleCouponRepository.php

@@ -38,6 +38,7 @@ class CartRuleCouponRepository extends Repository
                 'usage_per_customer' => $cartRule->usage_per_customer ?? 0,
                 'is_primary'         => 0,
                 'expired_at'         => $cartRule->ends_till ?: null,
+                'customer_id'        => $data['customer_id'] ?? null,
             ]);
         }
     }

+ 3 - 0
packages/Webkul/CartRule/src/Repositories/CartRuleRepository.php

@@ -68,6 +68,7 @@ class CartRuleRepository extends Repository
                 'usage_per_customer' => $data['usage_per_customer'] ?? 0,
                 'is_primary'         => 1,
                 'expired_at'         => $data['ends_till'] ?? null,
+                'customer_id'        => $data['customer_id'] ?? null,
             ]);
         }
 
@@ -110,6 +111,7 @@ class CartRuleRepository extends Repository
                         'usage_limit'        => $data['uses_per_coupon'] ?? 0,
                         'usage_per_customer' => $data['usage_per_customer'] ?? 0,
                         'expired_at'         => $data['ends_till'] ?? null,
+                        'customer_id'        => $data['customer_id'] ?? null,
                     ], $cartRuleCoupon->id);
                 } else {
                     $this->cartRuleCouponRepository->create([
@@ -119,6 +121,7 @@ class CartRuleRepository extends Repository
                         'usage_per_customer' => $data['usage_per_customer'] ?? 0,
                         'is_primary'         => 1,
                         'expired_at'         => $data['ends_till'] ?? null,
+                        'customer_id'        => $data['customer_id'] ?? null,
                     ]);
                 }
             } else {

+ 11 - 0
packages/Webkul/Shop/src/Http/Controllers/API/CartController.php

@@ -220,6 +220,17 @@ class CartController extends APIController
                     ]))->response()->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
                 }
 
+                // Check if coupon is restricted to a specific customer
+                if ($coupon->customer_id) {
+                    $cart = Cart::getCart();
+                    if (! $cart || ! $cart->customer_id || $cart->customer_id != $coupon->customer_id) {
+                        return (new JsonResource([
+                            'data'     => new CartResource(Cart::getCart()),
+                            'message'  => trans('shop::app.checkout.coupon.invalid'),
+                        ]))->response()->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
+                    }
+                }
+
                 if ($coupon->cart_rule->status) {
                     if (Cart::getCart()->coupon_code == $coupon->code) {
                         return (new JsonResource([

+ 203 - 0
packages/Webkul/Shop/src/Http/Controllers/API/CouponController.php

@@ -0,0 +1,203 @@
+<?php
+
+namespace Webkul\Shop\Http\Controllers\API;
+
+use Carbon\Carbon;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\DB;
+use Longyi\RewardPoints\Helpers\ApiResponse;
+use Webkul\CartRule\Repositories\CartRuleCouponRepository;
+use Webkul\CartRule\Repositories\CartRuleRepository;
+
+class CouponController extends APIController
+{
+    protected string $charset = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
+
+    protected int $codeLength = 8;
+
+    public function __construct(
+        protected CartRuleCouponRepository $cartRuleCouponRepository,
+        protected CartRuleRepository $cartRuleRepository
+    ) {}
+
+    /**
+     * List all coupons for the current customer.
+     */
+    public function index(): JsonResponse
+    {
+        $customer = auth()->guard('customer')->user();
+        if (! $customer) {
+            return ApiResponse::unauthorized();
+        }
+
+        $coupons = DB::table('cart_rule_coupons')
+            ->join('cart_rules', 'cart_rule_coupons.cart_rule_id', '=', 'cart_rules.id')
+            ->leftJoin('cart_rule_coupon_usage', function ($join) use ($customer) {
+                $join->on('cart_rule_coupons.id', '=', 'cart_rule_coupon_usage.cart_rule_coupon_id')
+                    ->where('cart_rule_coupon_usage.customer_id', '=', $customer->id);
+            })
+            ->where('cart_rule_coupons.customer_id', $customer->id)
+            ->where('cart_rules.status', 1)
+            ->select(
+                'cart_rule_coupons.id',
+                'cart_rule_coupons.code',
+                'cart_rule_coupons.cart_rule_id',
+                'cart_rule_coupons.expired_at',
+                'cart_rule_coupons.usage_limit',
+                'cart_rule_coupons.usage_per_customer',
+                'cart_rule_coupons.times_used',
+                'cart_rule_coupons.created_at',
+                'cart_rules.name as rule_name',
+                'cart_rules.description as rule_description',
+                'cart_rules.action_type',
+                'cart_rules.discount_amount',
+                'cart_rules.ends_till',
+                'cart_rules.starts_from',
+                'cart_rule_coupon_usage.times_used as my_used'
+            )
+            ->orderBy('cart_rule_coupons.created_at', 'desc')
+            ->get()
+            ->map(function ($coupon) {
+                $now = Carbon::now();
+                $status = 'available';
+
+                if ($coupon->expired_at && Carbon::parse($coupon->expired_at)->lt($now)) {
+                    $status = 'expired';
+                } elseif ($coupon->ends_till && Carbon::parse($coupon->ends_till)->lt($now)) {
+                    $status = 'expired';
+                } elseif ($coupon->starts_from && Carbon::parse($coupon->starts_from)->gt($now)) {
+                    $status = 'not_started';
+                } elseif ($coupon->usage_limit && $coupon->times_used >= $coupon->usage_limit) {
+                    $status = 'exhausted';
+                } elseif ($coupon->usage_per_customer && $coupon->my_used >= $coupon->usage_per_customer) {
+                    $status = 'used';
+                }
+
+                $discountText = match ($coupon->action_type) {
+                    'by_percent' => floor($coupon->discount_amount).'%',
+                    'by_fixed'   => '$'.floor($coupon->discount_amount),
+                    'cart_fixed' => '$'.floor($coupon->discount_amount).' (whole cart)',
+                    'buy_x_get_y' => 'Buy X Get Y Free',
+                    default      => $coupon->discount_amount,
+                };
+
+                return [
+                    'id'              => $coupon->id,
+                    'code'            => $coupon->code,
+                    'cart_rule_id'    => $coupon->cart_rule_id,
+                    'rule_name'       => $coupon->rule_name,
+                    'rule_description'=> $coupon->rule_description,
+                    'discount_text'   => $discountText,
+                    'status'          => $status,
+                    'expired_at'      => $coupon->expired_at,
+                    'starts_from'     => $coupon->starts_from,
+                    'ends_till'       => $coupon->ends_till,
+                    'created_at'      => $coupon->created_at,
+                ];
+            });
+
+        return ApiResponse::success($coupons);
+    }
+
+    /**
+     * Claim a coupon by rule ID. Auto-generates a short code and binds to the current customer.
+     */
+    public function claim(): JsonResponse
+    {
+        try {
+            $customer = auth()->guard('customer')->user();
+            if (! $customer) {
+                return ApiResponse::unauthorized();
+            }
+
+            $validated = request()->validate([
+                'rule_id' => 'required|integer|exists:cart_rules,id',
+            ]);
+
+            $rule = $this->cartRuleRepository->find($validated['rule_id']);
+            if (! $rule) {
+                return ApiResponse::notFound('Cart rule not found');
+            }
+            if (! $rule->status) {
+                return ApiResponse::error('This coupon rule is currently inactive');
+            }
+            if ($rule->starts_from && Carbon::parse($rule->starts_from)->gt(Carbon::now())) {
+                return ApiResponse::error('This coupon rule has not started yet');
+            }
+            if ($rule->ends_till && Carbon::parse($rule->ends_till)->lt(Carbon::now())) {
+                return ApiResponse::error('This coupon rule has expired');
+            }
+            if (! $rule->coupon_type) {
+                return ApiResponse::error('This rule does not require a coupon code');
+            }
+
+            $existing = $this->cartRuleCouponRepository->findOneWhere([
+                'cart_rule_id' => $rule->id,
+                'customer_id'  => $customer->id,
+            ]);
+            if ($existing) {
+                return ApiResponse::success([
+                    'id'              => $existing->id,
+                    'code'            => $existing->code,
+                    'rule_name'       => $rule->name,
+                    'action_type'     => $rule->action_type,
+                    'discount_amount' => $rule->discount_amount,
+                    'expired_at'      => $existing->expired_at,
+                ], 'You have already claimed this coupon');
+            }
+
+            $code = $this->generateUniqueCode();
+            $coupon = $this->createCoupon([
+                'cart_rule_id'       => $rule->id,
+                'code'               => $code,
+                'usage_limit'        => $rule->uses_per_coupon ?: 1,
+                'usage_per_customer' => $rule->usage_per_customer ?: 1,
+                'customer_id'        => $customer->id,
+                'is_primary'         => 0,
+                'expired_at'         => $rule->ends_till ?: null,
+            ]);
+
+            return ApiResponse::success([
+                'id'              => $coupon->id,
+                'code'            => $coupon->code,
+                'rule_name'       => $rule->name,
+                'action_type'     => $rule->action_type,
+                'discount_amount' => $rule->discount_amount,
+                'expired_at'      => $coupon->expired_at,
+            ], 'Coupon claimed successfully');
+        } catch (\Exception $e) {
+            return ApiResponse::error($e->getMessage());
+        }
+    }
+
+    protected function generateUniqueCode(): string
+    {
+        $retries = 5;
+        do {
+            $code = '';
+            for ($i = 0; $i < $this->codeLength; $i++) {
+                $code .= $this->charset[random_int(0, strlen($this->charset) - 1)];
+            }
+        } while (DB::table('cart_rule_coupons')->where('code', $code)->exists() && --$retries > 0);
+
+        return $code;
+    }
+
+    protected function createCoupon(array $data)
+    {
+        $retries = 3;
+        while ($retries > 0) {
+            try {
+                return $this->cartRuleCouponRepository->create($data);
+            } catch (\Illuminate\Database\QueryException $e) {
+                if ($e->getCode() == 23000 && $retries > 1) {
+                    $retries--;
+                    $data['code'] = $this->generateUniqueCode();
+                    continue;
+                }
+                throw $e;
+            }
+        }
+        throw new \RuntimeException('Failed to generate unique coupon code');
+    }
+}

+ 13 - 0
packages/Webkul/Shop/src/Resources/lang/en/app.php

@@ -356,6 +356,19 @@ return [
                 'title'               => 'Title',
             ],
 
+            'coupons' => [
+                'already-applied'      => 'This coupon is already applied to your cart.',
+                'cannot-apply'         => 'Could not apply coupon, please check cart conditions.',
+                'claimed'              => 'Coupon claimed and applied successfully.',
+                'expired'              => 'This coupon has expired.',
+                'no-coupons'           => 'No coupons available.',
+                'not-found'            => 'Coupon code not found.',
+                'not-yours'            => 'This coupon does not belong to you.',
+                'page-title'           => 'My Coupons',
+                'rule-inactive'        => 'This coupon rule is currently inactive.',
+                'usage-limit-reached'  => 'This coupon has reached its usage limit.',
+            ],
+
             'wishlist' => [
                 'color'              => 'Color',
                 'delete-all'         => 'Delete All',

+ 13 - 0
packages/Webkul/Shop/src/Resources/lang/zh_CN/app.php

@@ -356,6 +356,19 @@ return [
                 'title'               => '名称',
             ],
 
+            'coupons' => [
+                'already-applied'      => '此优惠券已应用于您的购物车。',
+                'cannot-apply'         => '无法使用该优惠券,请检查购物车条件。',
+                'claimed'              => '优惠券领取成功并已应用到购物车。',
+                'expired'              => '此优惠券已过期。',
+                'no-coupons'           => '暂无可用的优惠券。',
+                'not-found'            => '未找到此优惠券。',
+                'not-yours'            => '此优惠券不属于您。',
+                'page-title'           => '我的优惠券',
+                'rule-inactive'        => '该优惠券规则当前未激活。',
+                'usage-limit-reached'  => '此优惠券已达到使用次数上限。',
+            ],
+
             'wishlist' => [
                 'color'              => '颜色',
                 'delete-all'         => '全部删除',

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

@@ -2,6 +2,7 @@
 
 use Illuminate\Support\Facades\Route;
 use Webkul\Shop\Http\Controllers\API\BearerAddressController;
+use Webkul\Shop\Http\Controllers\API\CouponController;
 
 /**
  * Bearer Token 认证的 API 路由
@@ -24,4 +25,13 @@ Route::group(['prefix' => 'api'], function () {
             Route::delete('{id}', 'destroy')->name('shop.api.token.customer.addresses.destroy');
         });
 
+    /** 专属优惠券(Bearer Token 认证) */
+    Route::controller(CouponController::class)
+        ->middleware('bearer.token')
+        ->prefix('customer/coupons')
+        ->group(function () {
+            Route::get('', 'index')->name('shop.api.token.customer.coupons.index');
+            Route::post('claim', 'claim')->name('shop.api.token.customer.coupons.claim');
+        });
+
 });