llp 4 روز پیش
والد
کامیت
e68e1d1d4b
48فایلهای تغییر یافته به همراه5248 افزوده شده و 61 حذف شده
  1. 62 60
      bootstrap/cache/services.php
  2. 1 0
      bootstrap/providers.php
  3. 1 0
      composer.json
  4. 84 0
      packages/Longyi/FreeGift/src/Config/Gift.php
  5. 43 0
      packages/Longyi/FreeGift/src/Config/system.php
  6. 7 0
      packages/Longyi/FreeGift/src/Contracts/FreeGiftItem.php
  7. 7 0
      packages/Longyi/FreeGift/src/Contracts/FreeGiftRule.php
  8. 49 0
      packages/Longyi/FreeGift/src/Database/Migrations/2026_09_17_000001_create_free_gift_rules_table.php
  9. 53 0
      packages/Longyi/FreeGift/src/Database/Migrations/2026_09_17_000002_create_free_gift_items_table.php
  10. 82 0
      packages/Longyi/FreeGift/src/Http/Controllers/Admin/ProductPickerController.php
  11. 219 0
      packages/Longyi/FreeGift/src/Http/Controllers/Shop/GiftController.php
  12. 34 0
      packages/Longyi/FreeGift/src/Http/Requests/GiftCartRuleRequest.php
  13. 30 0
      packages/Longyi/FreeGift/src/Listeners/AdminCartRuleForm.php
  14. 31 0
      packages/Longyi/FreeGift/src/Listeners/Cart.php
  15. 39 0
      packages/Longyi/FreeGift/src/Listeners/CartItemDelete.php
  16. 33 0
      packages/Longyi/FreeGift/src/Listeners/CartItemUpdate.php
  17. 79 0
      packages/Longyi/FreeGift/src/Listeners/OrderPlaced.php
  18. 118 0
      packages/Longyi/FreeGift/src/Models/FreeGiftItem.php
  19. 107 0
      packages/Longyi/FreeGift/src/Models/FreeGiftRule.php
  20. 44 0
      packages/Longyi/FreeGift/src/Providers/EventServiceProvider.php
  21. 64 0
      packages/Longyi/FreeGift/src/Providers/FreeGiftServiceProvider.php
  22. 83 0
      packages/Longyi/FreeGift/src/Repositories/FreeGiftItemRepository.php
  23. 58 0
      packages/Longyi/FreeGift/src/Repositories/FreeGiftRuleRepository.php
  24. 146 0
      packages/Longyi/FreeGift/src/Repositories/GiftCartRuleRepository.php
  25. 60 0
      packages/Longyi/FreeGift/src/Resources/lang/en/admin.php
  26. 24 0
      packages/Longyi/FreeGift/src/Resources/lang/en/app.php
  27. 60 0
      packages/Longyi/FreeGift/src/Resources/lang/zh_CN/admin.php
  28. 24 0
      packages/Longyi/FreeGift/src/Resources/lang/zh_CN/app.php
  29. 1244 0
      packages/Longyi/FreeGift/src/Resources/views/admin/cart-rules/gift-settings.blade.php
  30. 342 0
      packages/Longyi/FreeGift/src/Resources/views/shop/cart/gift-container.blade.php
  31. 18 0
      packages/Longyi/FreeGift/src/Resources/views/shop/cart/gift-item-badge.blade.php
  32. 15 0
      packages/Longyi/FreeGift/src/Routes/admin-routes.php
  33. 21 0
      packages/Longyi/FreeGift/src/Routes/shop-routes.php
  34. 131 0
      packages/Longyi/FreeGift/src/Services/Actions/AbstractGiftAction.php
  35. 22 0
      packages/Longyi/FreeGift/src/Services/Actions/CartAction.php
  36. 38 0
      packages/Longyi/FreeGift/src/Services/Actions/EachNAction.php
  37. 40 0
      packages/Longyi/FreeGift/src/Services/Actions/GiftActionPool.php
  38. 26 0
      packages/Longyi/FreeGift/src/Services/Actions/ProductAction.php
  39. 69 0
      packages/Longyi/FreeGift/src/Services/Actions/SameProductAction.php
  40. 27 0
      packages/Longyi/FreeGift/src/Services/Actions/SpentAction.php
  41. 77 0
      packages/Longyi/FreeGift/src/Services/GiftDiscountCalculator.php
  42. 334 0
      packages/Longyi/FreeGift/src/Services/GiftItem.php
  43. 237 0
      packages/Longyi/FreeGift/src/Services/GiftItemsGroup.php
  44. 572 0
      packages/Longyi/FreeGift/src/Services/GiftProcessor.php
  45. 52 0
      packages/Longyi/FreeGift/src/Services/GiftProductValidator.php
  46. 158 0
      packages/Longyi/FreeGift/src/Services/GiftRegistry.php
  47. 168 0
      packages/Longyi/FreeGift/src/Services/GiftRuleResolver.php
  48. 15 1
      packages/Webkul/Shop/src/Http/Resources/CartItemResource.php

+ 62 - 60
bootstrap/cache/services.php

@@ -73,36 +73,37 @@
     69 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
     70 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
     71 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
-    72 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
-    73 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
-    74 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
-    75 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
-    76 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
-    77 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
-    78 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
-    79 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
-    80 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
-    81 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
-    82 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
-    83 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
-    84 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
-    85 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
-    86 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
-    87 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
-    88 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
-    89 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
-    90 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
-    91 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
-    92 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
-    93 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
-    94 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
-    95 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
-    96 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
-    97 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
-    98 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
-    99 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
-    100 => 'Webkul\\User\\Providers\\UserServiceProvider',
-    101 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
+    72 => 'Longyi\\FreeGift\\Providers\\FreeGiftServiceProvider',
+    73 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    74 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    75 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    76 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    77 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    78 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    79 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    80 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    81 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    82 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    83 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    84 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    85 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    86 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    87 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    88 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    89 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    90 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    91 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    92 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    93 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    94 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    95 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    96 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    97 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    98 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    99 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    100 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    101 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    102 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'eager' => 
   array (
@@ -161,36 +162,37 @@
     52 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
     53 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
     54 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
-    55 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
-    56 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
-    57 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
-    58 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
-    59 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
-    60 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
-    61 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
-    62 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
-    63 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
-    64 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
-    65 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
-    66 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
-    67 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
-    68 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
-    69 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
-    70 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
-    71 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
-    72 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
-    73 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
-    74 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
-    75 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
-    76 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
-    77 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
-    78 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
-    79 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
-    80 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
-    81 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
-    82 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
-    83 => 'Webkul\\User\\Providers\\UserServiceProvider',
-    84 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
+    55 => 'Longyi\\FreeGift\\Providers\\FreeGiftServiceProvider',
+    56 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    57 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    58 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    59 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    60 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    61 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    62 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    63 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    64 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    65 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    66 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    67 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    68 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    69 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    70 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    71 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    72 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    73 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    74 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    75 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    76 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    77 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    78 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    79 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    80 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    81 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    82 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    83 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    84 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    85 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'deferred' => 
   array (

+ 1 - 0
bootstrap/providers.php

@@ -30,6 +30,7 @@ return [
     Webkul\BookingProduct\Providers\BookingProductServiceProvider::class,
     Webkul\CMS\Providers\CMSServiceProvider::class,
     Webkul\CartRule\Providers\CartRuleServiceProvider::class,
+    Longyi\FreeGift\Providers\FreeGiftServiceProvider::class,
     Webkul\CatalogRule\Providers\CatalogRuleServiceProvider::class,
     Webkul\Category\Providers\CategoryServiceProvider::class,
     Webkul\Checkout\Providers\CheckoutServiceProvider::class,

+ 1 - 0
composer.json

@@ -86,6 +86,7 @@
             "Longyi\\RewardPoints\\": "packages/Longyi/RewardPoints/src/",
             "Longyi\\Member\\": "packages/Longyi/Member/src/",
             "Longyi\\Gift\\": "packages/Longyi/Gift/src/",
+            "Longyi\\FreeGift\\": "packages/Longyi/FreeGift/src/",
             "Longyi\\ShippingInsurance\\": "packages/Longyi/ShippingInsurance/src/",
             "Longyi\\Pay\\Airwallex\\": "packages/Longyi/Pay/Airwallex/src/",
             "Longyi\\Pay\\Applepay\\": "packages/Longyi/Pay/Applepay/src/",

+ 84 - 0
packages/Longyi/FreeGift/src/Config/Gift.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace Longyi\FreeGift\Config;
+
+/**
+ * 赠品活动(Free Gift)的全局常量。
+ *
+ * 5 种规则类型直接复用 Bagisto 的 `cart_rules.action_type` 字段,
+ * 不额外建 action 枚举表,和原生的 by_percent / by_fixed / cart_fixed / buy_x_get_y 并列。
+ */
+class Gift
+{
+    /**
+     * 买它送它:购买指定商品本身,按 discount_step 送 discount_amount 个同款赠品。
+     */
+    public const ACTION_SAME_PRODUCT = 'free_gift_same_product';
+
+    /**
+     * 买指定商品送:购物车中存在满足条件的商品时赠送。
+     */
+    public const ACTION_WITH_PRODUCTS = 'free_gift_with_products';
+
+    /**
+     * 每 N 件送:每买满 discount_step 件,送 discount_amount 个赠品。
+     */
+    public const ACTION_EACH_N = 'free_gift_each_n';
+
+    /**
+     * 整车送:只要满足条件(通常条件为空)就送。
+     */
+    public const ACTION_WHOLE_CART = 'free_gift_whole_cart';
+
+    /**
+     * 满额送:满足条件商品的金额每达到 discount_step,送 discount_amount 个赠品。
+     */
+    public const ACTION_SPENT = 'free_gift_spent';
+
+    /**
+     * 全部赠品型 action。
+     */
+    public const ACTION_TYPES = [
+        self::ACTION_SAME_PRODUCT,
+        self::ACTION_WITH_PRODUCTS,
+        self::ACTION_EACH_N,
+        self::ACTION_WHOLE_CART,
+        self::ACTION_SPENT,
+    ];
+
+    /**
+     * 赠品组类型:列表里每个 SKU 各送 N 个。
+     */
+    public const TYPE_ALL = 0;
+
+    /**
+     * 赠品组类型:列表合计只送 N 个,由用户自选。
+     */
+    public const TYPE_ONE = 1;
+
+    /**
+     * `cart_items.additional` 中标记「该行是赠品」的键,值为 cart_rules.id。
+     */
+    public const CART_ITEM_FLAG = 'free_gift_rule_id';
+
+    /**
+     * 判定 action_type 是否为赠品型。
+     */
+    public static function isGiftAction($actionType): bool
+    {
+        return in_array($actionType, self::ACTION_TYPES, true);
+    }
+
+    /**
+     * 判定 action_type 是否是「必须绑定商品条件」的类型。
+     */
+    public static function requiresProductCondition($actionType): bool
+    {
+        return in_array($actionType, [
+            self::ACTION_SAME_PRODUCT,
+            self::ACTION_WITH_PRODUCTS,
+            self::ACTION_EACH_N,
+            self::ACTION_SPENT,
+        ], true);
+    }
+}

+ 43 - 0
packages/Longyi/FreeGift/src/Config/system.php

@@ -0,0 +1,43 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| 赠品活动(Free Gift)—— 系统配置 → 销售
+|--------------------------------------------------------------------------
+|
+| ⚠ 注意 key 的层级:Bagisto 的 SystemConfig::prepareConfigurationItems() 是
+| 用 `Arr::undot(Arr::dot($item['key'] => $item))` 按 key 拼树的,然后
+| processSubConfigItems() 只保留「自己带 name 的节点」。
+| 所以三段 key(sales.free_gift.general)必须有对应的二段父级条目
+| (sales.free_gift),否则整个分组会被静默丢掉 —— 不报错,也不出现在页面上。
+|
+*/
+
+return [
+    [
+        'key'  => 'sales.free_gift',
+        'name' => 'free_gift::admin.system.title',
+        'info' => 'free_gift::admin.system.info',
+        'sort' => 8,
+    ], [
+        'key'    => 'sales.free_gift.general',
+        'name'   => 'free_gift::admin.system.general',
+        'info'   => 'free_gift::admin.system.general-info',
+        'sort'   => 1,
+        'fields' => [
+            [
+                'name'          => 'disable_auto_add',
+                'title'         => 'free_gift::admin.system.disable-auto-add',
+                'type'          => 'boolean',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'show_banner',
+                'title'         => 'free_gift::admin.system.show-banner',
+                'type'          => 'boolean',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ],
+        ],
+    ],
+];

+ 7 - 0
packages/Longyi/FreeGift/src/Contracts/FreeGiftItem.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Longyi\FreeGift\Contracts;
+
+interface FreeGiftItem
+{
+}

+ 7 - 0
packages/Longyi/FreeGift/src/Contracts/FreeGiftRule.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Longyi\FreeGift\Contracts;
+
+interface FreeGiftRule
+{
+}

+ 49 - 0
packages/Longyi/FreeGift/src/Database/Migrations/2026_09_17_000001_create_free_gift_rules_table.php

@@ -0,0 +1,49 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('free_gift_rules', function (Blueprint $table) {
+            $table->increments('id');
+
+            // 关联的营销规则(cart_rules)
+            $table->unsignedInteger('cart_rule_id')->unique()->comment('关联的 cart_rules.id');
+
+            // 赠品配置
+            $table->text('sku')->nullable()->comment('赠品 SKU 列表,逗号分隔');
+            $table->unsignedTinyInteger('type')->default(0)->comment('赠品组类型:0-每个SKU各送N 1-一组合计送N');
+            $table->string('items_discount')->nullable()->comment('折扣写法:100% / 50% / -10 / 一口价 99');
+
+            // 附加限制
+            $table->decimal('minimal_items_price', 12, 4)->nullable()->comment('赠品折后最低价');
+            $table->boolean('apply_tax')->default(1)->comment('赠品是否参与税费计算');
+            $table->boolean('apply_shipping')->default(0)->comment('是否计算赠品运费');
+            $table->boolean('auto_add')->default(1)->comment('是否自动加入购物车');
+
+            // 前台 Banner
+            $table->boolean('show_banner')->default(0)->comment('购物车是否展示 Banner');
+            $table->string('banner_image')->nullable()->comment('Banner 图片');
+            $table->string('banner_alt')->nullable()->comment('Banner 文案');
+
+            $table->timestamps();
+
+            $table->index('cart_rule_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('free_gift_rules');
+    }
+};

+ 53 - 0
packages/Longyi/FreeGift/src/Database/Migrations/2026_09_17_000002_create_free_gift_items_table.php

@@ -0,0 +1,53 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('free_gift_items', function (Blueprint $table) {
+            $table->increments('id');
+
+            // 归属
+            $table->unsignedInteger('cart_id')->comment('cart.id');
+            $table->unsignedInteger('cart_item_id')->nullable()->comment('对应的 cart_items.id');
+            $table->unsignedInteger('product_id')->nullable()->comment('赠品 product.id');
+
+            // 赠品与规则
+            $table->string('sku')->comment('赠品 SKU');
+            $table->unsignedInteger('rule_id')->comment('cart_rules.id');
+            $table->unsignedTinyInteger('rule_type')->default(0)->comment('赠品组类型:0-每个SKU各送N 1-一组合计送N');
+
+            // 数量
+            $table->decimal('allowed_qty', 12, 4)->default(0)->comment('规则允许赠送的总数');
+            $table->decimal('reserved_qty', 12, 4)->default(0)->comment('已加入购物车的数量');
+
+            // 折扣
+            $table->string('discount_amount')->nullable()->comment('该赠品适用的折扣写法');
+
+            // 状态
+            $table->boolean('auto_add')->default(1)->comment('是否自动加购');
+            $table->boolean('is_item_deleted')->default(0)->comment('用户是否手动删除过');
+
+            $table->timestamps();
+
+            $table->unique(['cart_id', 'sku', 'rule_id'], 'FREE_GIFT_ITEM_CART_SKU_RULE_UNIQUE');
+            $table->index(['cart_id', 'rule_id']);
+            $table->index('cart_item_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('free_gift_items');
+    }
+};

+ 82 - 0
packages/Longyi/FreeGift/src/Http/Controllers/Admin/ProductPickerController.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace Longyi\FreeGift\Http\Controllers\Admin;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Longyi\FreeGift\Services\GiftProductValidator;
+use Webkul\Admin\Http\Controllers\Controller;
+use Webkul\Product\Models\ProductFlat;
+
+/**
+ * 赠品 SKU 选择器(购物车规则 → 赠品设置里那个「选择商品」弹窗)的数据源。
+ *
+ * 只读接口,空关键词返回全部商品;数据取当前渠道 + 当前语言的 product_flat,
+ * 一个商品一行(product_flat 是「商品 + 渠道 + 语言」的展开表)。
+ */
+class ProductPickerController extends Controller
+{
+    /**
+     * 每页条数。
+     */
+    protected const PER_PAGE = 20;
+
+    /**
+     * 商品列表。
+     */
+    public function index(Request $request): JsonResponse
+    {
+        $keyword = $this->escapeLike(trim((string) $request->input('query')));
+
+        $products = ProductFlat::query()
+            ->select(['product_id', 'sku', 'name', 'type', 'status', 'price'])
+            ->where('channel', core()->getCurrentChannelCode())
+            ->where('locale', app()->getLocale())
+            /*
+             * 排除可配置商品的子商品(parent_id 有值):赠品行是绕过
+             * prepareForCart() 直接写 cart_items 的,子商品单独进购物车
+             * 在渲染 / 税费 / 下单链路里没有配套的可配置选项,会出问题。
+             */
+            ->whereNull('parent_id')
+            ->when($keyword !== '', function ($query) use ($keyword) {
+                $query->where(function ($subQuery) use ($keyword) {
+                    $subQuery->where('name', 'like', '%'.$keyword.'%')
+                        ->orWhere('sku', 'like', '%'.$keyword.'%');
+                });
+            })
+            ->orderBy('name')
+            ->paginate(self::PER_PAGE);
+
+        $products->through(fn ($product) => [
+            'id'       => (int) $product->product_id,
+            'sku'      => (string) $product->sku,
+            'name'     => (string) $product->name,
+            'type'     => (string) $product->type,
+            'price'    => core()->formatBasePrice((float) $product->price),
+            /*
+             * 只有「启用 + 简单/虚拟」的商品才真的能被当赠品送出去
+             * (判定口径见 GiftProductValidator::ALLOWED_TYPES),
+             * 这里只是给后台一个提示,不拦着不让选。
+             */
+            'giftable' => (bool) $product->status
+                && in_array($product->type, GiftProductValidator::ALLOWED_TYPES, true),
+        ]);
+
+        return response()->json([
+            'data' => $products->items(),
+            'meta' => [
+                'current_page' => $products->currentPage(),
+                'last_page'    => $products->lastPage(),
+                'total'        => $products->total(),
+            ],
+        ]);
+    }
+
+    /**
+     * 转义 LIKE 里的通配符(SKU 里下划线/百分号很常见,不转义会搜错)。
+     */
+    protected function escapeLike(string $keyword): string
+    {
+        return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $keyword);
+    }
+}

+ 219 - 0
packages/Longyi/FreeGift/src/Http/Controllers/Shop/GiftController.php

@@ -0,0 +1,219 @@
+<?php
+
+namespace Longyi\FreeGift\Http\Controllers\Shop;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Routing\Controller;
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Longyi\FreeGift\Repositories\FreeGiftRuleRepository;
+use Longyi\FreeGift\Services\GiftProcessor;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Product\ProductImage;
+use Webkul\Product\Repositories\ProductRepository;
+
+/**
+ * 前台赠品接口。
+ *
+ * 对应 Amasty 的 Controller\Cart\Add / Controller\Popup。
+ */
+class GiftController extends Controller
+{
+    public function __construct(
+        protected FreeGiftItemRepository $giftItemRepository,
+        protected FreeGiftRuleRepository $giftRuleRepository,
+        protected ProductRepository $productRepository,
+        protected ProductImage $productImage,
+        protected GiftProcessor $processor
+    ) {}
+
+    /**
+     * 当前购物车的赠品候选(弹窗 / Banner 数据源)。
+     */
+    public function candidates(): JsonResponse
+    {
+        $payload = [
+            'groups'  => [],
+            'banners' => [],
+        ];
+
+        if (! Cart::getCart()) {
+            return new JsonResponse(['data' => $payload]);
+        }
+
+        Cart::collectTotals();
+
+        $cart = Cart::getCart();
+
+        if (! $cart) {
+            return new JsonResponse(['data' => $payload]);
+        }
+
+        $groups = [];
+
+        foreach ($this->giftItemRepository->getByCart($cart->id) as $row) {
+            if ($row->allowed_qty < 1) {
+                continue;
+            }
+
+            $giftRule = $this->giftRuleRepository->findByCartRuleId($row->rule_id);
+
+            if (! $giftRule) {
+                continue;
+            }
+
+            $product = $row->product_id ? $this->productRepository->find($row->product_id) : null;
+
+            if (! $product) {
+                continue;
+            }
+
+            $ruleId = (int) $row->rule_id;
+
+            if (! isset($groups[$ruleId])) {
+                $groups[$ruleId] = [
+                    'rule_id'       => $ruleId,
+                    'rule_name'     => $giftRule->cart_rule?->name,
+                    'type'          => (int) $row->rule_type,
+                    'auto_add'      => (bool) $row->auto_add,
+                    'allowed_qty'   => 0,
+                    'reserved_qty'  => 0,
+                    'remaining_qty' => 0,
+                    'gifts'         => [],
+                ];
+            }
+
+            $image = $this->productImage->getProductBaseImage($product);
+
+            $groups[$ruleId]['gifts'][] = [
+                'sku'           => $row->sku,
+                'product_id'    => (int) $product->id,
+                'name'          => $product->name,
+                'url'           => route('shop.product_or_category.index', $product->url_key),
+                'image'         => $image['medium_image_url'] ?? null,
+                'price'         => core()->formatPrice((float) $product->price),
+                'allowed_qty'   => (float) $row->allowed_qty,
+                'reserved_qty'  => (float) $row->reserved_qty,
+                'remaining_qty' => max(0, (float) $row->allowed_qty - (float) $row->reserved_qty),
+                'cart_item_id'  => $row->cart_item_id ? (int) $row->cart_item_id : null,
+            ];
+        }
+
+        foreach ($groups as $ruleId => $group) {
+            $allowed = array_column($group['gifts'], 'allowed_qty');
+            $reserved = array_column($group['gifts'], 'reserved_qty');
+
+            // 「一组合计送 N 个」:整组共用 N 个名额,N 取单条的允许量而不是求和
+            $group['allowed_qty'] = $group['type'] === Gift::TYPE_ONE
+                ? max($allowed)
+                : array_sum($allowed);
+
+            $group['reserved_qty'] = array_sum($reserved);
+
+            $group['remaining_qty'] = max(0, $group['allowed_qty'] - $group['reserved_qty']);
+
+            $groups[$ruleId] = $group;
+        }
+
+        $groups = array_values($groups);
+
+        return new JsonResponse([
+            'data' => [
+                'groups'  => $groups,
+                'banners' => $this->buildBanners($groups),
+            ],
+        ]);
+    }
+
+    /**
+     * 手动把赠品加进购物车(手动模式 / 弹窗选择)。
+     */
+    public function add(): JsonResponse
+    {
+        $data = request()->validate([
+            'sku'     => ['required', 'string'],
+            'rule_id' => ['required', 'integer'],
+            'qty'     => ['nullable', 'numeric', 'min:1'],
+        ]);
+
+        $cart = Cart::getCart();
+
+        if (! $cart) {
+            return new JsonResponse([
+                'message' => trans('free_gift::app.cart.gift-rule-inactive'),
+            ], 422);
+        }
+
+        $result = $this->processor->addGiftToCart(
+            $cart,
+            $data['sku'],
+            (int) $data['rule_id'],
+            (float) ($data['qty'] ?? 1)
+        );
+
+        if (! $result['success']) {
+            return new JsonResponse(['message' => $result['message']], 422);
+        }
+
+        Cart::collectTotals();
+
+        return new JsonResponse([
+            'message' => $result['message'],
+        ]);
+    }
+
+    /**
+     * 移除赠品。
+     *
+     * 走原生 removeItem(),删除监听器负责打 is_item_deleted 标记,
+     * 避免用户删掉之后又被自动加回来。
+     */
+    public function remove(): JsonResponse
+    {
+        $data = request()->validate([
+            'cart_item_id' => ['required', 'integer'],
+        ]);
+
+        Cart::removeItem((int) $data['cart_item_id']);
+
+        Cart::collectTotals();
+
+        return new JsonResponse([
+            'message' => trans('free_gift::app.cart.gift-removed'),
+        ]);
+    }
+
+    /**
+     * 需要展示 Banner 的规则。
+     *
+     * @param  array  $groups
+     * @return array
+     */
+    protected function buildBanners(array $groups): array
+    {
+        $banners = [];
+
+        // 全局总开关:System Configuration > Sales > Free Gift
+        if (! core()->getConfigData('sales.free_gift.general.show_banner')) {
+            return $banners;
+        }
+
+        foreach ($groups as $group) {
+            $giftRule = $this->giftRuleRepository->findByCartRuleId($group['rule_id']);
+
+            if (! $giftRule || ! $giftRule->show_banner) {
+                continue;
+            }
+
+            $banners[] = [
+                'rule_id'      => $group['rule_id'],
+                'text'         => $giftRule->banner_alt ?: $giftRule->cart_rule?->name,
+                'image'        => $giftRule->bannerImageUrl(),
+                'allowed_qty'  => $group['allowed_qty'],
+                'remaining_qty' => $group['remaining_qty'],
+            ];
+        }
+
+        return $banners;
+    }
+}

+ 34 - 0
packages/Longyi/FreeGift/src/Http/Requests/GiftCartRuleRequest.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace Longyi\FreeGift\Http\Requests;
+
+use Webkul\Admin\Http\Requests\CartRuleRequest;
+
+/**
+ * 在原生营销规则表单的校验规则上,追加赠品配置字段。
+ */
+class GiftCartRuleRequest extends CartRuleRequest
+{
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return array_merge(parent::rules(), [
+            'free_gift'                     => ['nullable', 'array'],
+            'free_gift.sku'                 => ['nullable', 'string'],
+            'free_gift.type'                => ['nullable', 'in:0,1'],
+            'free_gift.items_discount'      => ['nullable', 'string'],
+            'free_gift.minimal_items_price' => ['nullable', 'numeric', 'min:0'],
+            'free_gift.apply_tax'           => ['nullable', 'in:0,1'],
+            'free_gift.apply_shipping'      => ['nullable', 'in:0,1'],
+            'free_gift.auto_add'            => ['nullable', 'in:0,1'],
+            'free_gift.show_banner'         => ['nullable', 'in:0,1'],
+            'free_gift.banner_image'        => ['nullable', 'string'],
+            'free_gift.banner_alt'          => ['nullable', 'string'],
+            'free_gift_banner_image_file'   => ['nullable', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
+        ]);
+    }
+}

+ 30 - 0
packages/Longyi/FreeGift/src/Listeners/AdminCartRuleForm.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Webkul\Theme\ViewRenderEventManager;
+
+/**
+ * 后台营销规则(Marketing > Promotions > Cart Rules)表单扩展。
+ *
+ * 用 view_render_event 往原生表单里注入,而不是整份覆盖 create/edit 视图:
+ * 原生模板各 60K+,一旦覆盖,Webkul 后续升级表单结构就要人工同步,代价太高。
+ */
+class AdminCartRuleForm
+{
+    /**
+     * 在右侧 Settings 抽屉之后追加「赠品设置」抽屉。
+     *
+     * create / edit 共用同一个模板:edit 场景下事件参数里带着 $cartRule,
+     * 模板自己会去 free_gift_rules 里回填已保存的配置。
+     *
+     * @param  mixed  $viewRenderEventManager
+     * @return void
+     */
+    public function giftSettings($viewRenderEventManager)
+    {
+        if ($viewRenderEventManager instanceof ViewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('free_gift::admin.cart-rules.gift-settings');
+        }
+    }
+}

+ 31 - 0
packages/Longyi/FreeGift/src/Listeners/Cart.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Longyi\FreeGift\Services\GiftProcessor;
+
+/**
+ * 赠品主入口。
+ *
+ * 挂在 checkout.cart.collect.totals.before 上,与原生促销
+ * Webkul\CartRule\Listeners\Cart@applyCartRules 并列。
+ *
+ * 这个时机很关键(Webkul\Checkout\Cart::collectTotals 第 849 行):
+ *  - 在 calculateItemsTax() 之前,也在 refreshCart() 之前
+ *  - refreshCart() 会把我们刚写进 cart_items 的赠品行重新载入,
+ *    因此后续的 totals 汇总会自动把赠品算进去
+ */
+class Cart
+{
+    public function __construct(
+        protected GiftProcessor $processor
+    ) {}
+
+    /**
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    public function applyFreeGifts($cart): void
+    {
+        $this->processor->process($cart);
+    }
+}

+ 39 - 0
packages/Longyi/FreeGift/src/Listeners/CartItemDelete.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Webkul\Checkout\Repositories\CartItemRepository;
+
+/**
+ * 用户手动删除赠品时打标记。
+ *
+ * 对应 Amasty 的 AmpPromo 删除拦截:如果不打标记,
+ * 下一次 collectTotals() 又会把赠品自动加回来,用户会以为删除无效。
+ */
+class CartItemDelete
+{
+    public function __construct(
+        protected CartItemRepository $cartItemRepository,
+        protected FreeGiftItemRepository $giftItemRepository
+    ) {}
+
+    /**
+     * @param  int  $itemId
+     */
+    public function handle($itemId): void
+    {
+        $item = $this->cartItemRepository->find($itemId);
+
+        if (! $item) {
+            return;
+        }
+
+        if (! ($item->additional[Gift::CART_ITEM_FLAG] ?? null)) {
+            return;
+        }
+
+        $this->giftItemRepository->markDeletedByCartItemId((int) $item->id);
+    }
+}

+ 33 - 0
packages/Longyi/FreeGift/src/Listeners/CartItemUpdate.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Longyi\FreeGift\Config\Gift;
+
+/**
+ * 禁止用户修改赠品数量。
+ *
+ * 挂在 checkout.cart.update.before 上(Webkul\Checkout\Cart::updateItems 第 374 行),
+ * 此时 $item->quantity 已经被赋成新值,而 getOriginal('quantity') 还是旧值。
+ *
+ * 注意:购物车页的更新表单会一次性提交整张表(包含赠品行),
+ * 所以只有在「数量真的被改动」时才拦截,否则用户连主商品的数量都改不了。
+ */
+class CartItemUpdate
+{
+    /**
+     * @param  \Webkul\Checkout\Contracts\CartItem  $item
+     */
+    public function handle($item): void
+    {
+        if (! ($item->additional[Gift::CART_ITEM_FLAG] ?? null)) {
+            return;
+        }
+
+        if ((float) $item->getOriginal('quantity') === (float) $item->quantity) {
+            return;
+        }
+
+        throw new \Exception(trans('free_gift::app.cart.gift-quantity-locked'));
+    }
+}

+ 79 - 0
packages/Longyi/FreeGift/src/Listeners/OrderPlaced.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Webkul\CartRule\Repositories\CartRuleCustomerRepository;
+use Webkul\CartRule\Repositories\CartRuleRepository;
+
+/**
+ * 下单成功后结算赠品用量。
+ *
+ * 赠品型规则不会进入 cart_rules 原生的 applied_cart_rule_ids,
+ * 所以 CartRule\Listeners\Order@manageCartRule 统计不到它们,
+ * times_used / cart_rule_customers 需要我们自己维护。
+ */
+class OrderPlaced
+{
+    public function __construct(
+        protected FreeGiftItemRepository $giftItemRepository,
+        protected CartRuleRepository $cartRuleRepository,
+        protected CartRuleCustomerRepository $cartRuleCustomerRepository
+    ) {}
+
+    /**
+     * @param  \Webkul\Sales\Contracts\Order  $order
+     */
+    public function handle($order): void
+    {
+        if (! $order || ! $order->cart_id) {
+            return;
+        }
+
+        $cartId = (int) $order->cart_id;
+
+        $ruleIds = $this->giftItemRepository->getModel()
+            ->where('cart_id', $cartId)
+            ->pluck('rule_id')
+            ->unique()
+            ->all();
+
+        foreach ($ruleIds as $ruleId) {
+            $rule = $this->cartRuleRepository->find($ruleId);
+
+            if (! $rule) {
+                continue;
+            }
+
+            // 注意:不能走 cartRuleRepository->update(),那是后台表单处理路径,
+            // 会强制读取 starts_from / ends_till / channels 等字段,只传 times_used
+            // 会抛 Undefined array key,并在 createOrderIfNotThenRetry 里触发无限重试
+            // (下单接口一直转圈的根因)。这里必须用模型层直接保存。
+            $rule->times_used = (int) $rule->times_used + 1;
+            $rule->save();
+
+            if (! $order->customer_id) {
+                continue;
+            }
+
+            $ruleCustomer = $this->cartRuleCustomerRepository->findOneWhere([
+                'cart_rule_id' => $rule->id,
+                'customer_id'  => $order->customer_id,
+            ]);
+
+            if ($ruleCustomer) {
+                $ruleCustomer->times_used = (int) $ruleCustomer->times_used + 1;
+                $ruleCustomer->save();
+            } else {
+                $this->cartRuleCustomerRepository->create([
+                    'cart_rule_id' => $rule->id,
+                    'customer_id'  => $order->customer_id,
+                    'times_used'   => 1,
+                ]);
+            }
+        }
+
+        // 订单已经生成,赠品状态行(绑在 cart 上)不再需要
+        $this->giftItemRepository->deleteByCart($cartId);
+    }
+}

+ 118 - 0
packages/Longyi/FreeGift/src/Models/FreeGiftItem.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace Longyi\FreeGift\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Contracts\FreeGiftItem as FreeGiftItemContract;
+use Webkul\CartRule\Models\CartRule;
+use Webkul\Checkout\Models\Cart;
+use Webkul\Checkout\Models\CartItem;
+
+/**
+ * 赠品在购物车中的状态(每个 购物车 + SKU + 规则 一行)。
+ *
+ * 对应 Amasty 的 amasty_ampromo_items_storage。
+ */
+class FreeGiftItem extends Model implements FreeGiftItemContract
+{
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = 'free_gift_items';
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'cart_id',
+        'cart_item_id',
+        'product_id',
+        'sku',
+        'rule_id',
+        'rule_type',
+        'allowed_qty',
+        'reserved_qty',
+        'discount_amount',
+        'auto_add',
+        'is_item_deleted',
+    ];
+
+    /**
+     * The attributes that should be cast.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'cart_id'         => 'integer',
+        'cart_item_id'    => 'integer',
+        'product_id'      => 'integer',
+        'rule_id'         => 'integer',
+        'rule_type'       => 'integer',
+        'allowed_qty'     => 'float',
+        'reserved_qty'    => 'float',
+        'auto_add'        => 'boolean',
+        'is_item_deleted' => 'boolean',
+    ];
+
+    /**
+     * 所属购物车。
+     */
+    public function cart(): BelongsTo
+    {
+        return $this->belongsTo(Cart::class, 'cart_id');
+    }
+
+    /**
+     * 对应的购物车行。
+     */
+    public function cart_item(): BelongsTo
+    {
+        return $this->belongsTo(CartItem::class, 'cart_item_id');
+    }
+
+    /**
+     * 赠送该赠品的规则。
+     */
+    public function cart_rule(): BelongsTo
+    {
+        return $this->belongsTo(CartRule::class, 'rule_id');
+    }
+
+    /**
+     * 规则是否允许「一组合计送 N 个」。
+     */
+    public function isOneOf(): bool
+    {
+        return (int) $this->rule_type === Gift::TYPE_ONE;
+    }
+
+    /**
+     * 规则是否允许「每个 SKU 各送 N 个」。
+     */
+    public function isAllOf(): bool
+    {
+        return (int) $this->rule_type === Gift::TYPE_ALL;
+    }
+
+    /**
+     * 用户是否手动删除过该赠品(删除后不再自动加购,直到购物车清空)。
+     */
+    public function isDeleted(): bool
+    {
+        return (bool) $this->is_item_deleted;
+    }
+
+    /**
+     * 还差几个需要补进购物车。
+     */
+    public function getQtyToProcessAttribute(): float
+    {
+        return max(0, (float) $this->allowed_qty - (float) $this->reserved_qty);
+    }
+}

+ 107 - 0
packages/Longyi/FreeGift/src/Models/FreeGiftRule.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace Longyi\FreeGift\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Support\Facades\Storage;
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Contracts\FreeGiftRule as FreeGiftRuleContract;
+use Webkul\CartRule\Models\CartRule;
+
+/**
+ * 赠品规则配置,1:1 挂在 `cart_rules` 上。
+ */
+class FreeGiftRule extends Model implements FreeGiftRuleContract
+{
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = 'free_gift_rules';
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'cart_rule_id',
+        'sku',
+        'type',
+        'items_discount',
+        'minimal_items_price',
+        'apply_tax',
+        'apply_shipping',
+        'auto_add',
+        'show_banner',
+        'banner_image',
+        'banner_alt',
+    ];
+
+    /**
+     * The attributes that should be cast.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'cart_rule_id'        => 'integer',
+        'type'                => 'integer',
+        'minimal_items_price' => 'float',
+        'apply_tax'           => 'boolean',
+        'apply_shipping'      => 'boolean',
+        'auto_add'            => 'boolean',
+        'show_banner'         => 'boolean',
+    ];
+
+    /**
+     * 关联的营销规则。
+     */
+    public function cart_rule(): BelongsTo
+    {
+        return $this->belongsTo(CartRule::class, 'cart_rule_id');
+    }
+
+    /**
+     * Banner 图片的公开访问地址。
+     */
+    public function bannerImageUrl(): ?string
+    {
+        if (empty($this->banner_image)) {
+            return null;
+        }
+
+        return Storage::url($this->banner_image);
+    }
+
+    /**
+     * 赠品 SKU 列表。
+     *
+     * @return string[]
+     */
+    public function skuList(): array
+    {
+        if (empty($this->sku)) {
+            return [];
+        }
+
+        return array_values(array_filter(array_map('trim', explode(',', $this->sku))));
+    }
+
+    /**
+     * 是否是「一组合计送 N 个」。
+     */
+    public function isOneOf(): bool
+    {
+        return (int) $this->type === Gift::TYPE_ONE;
+    }
+
+    /**
+     * 是否是「每个 SKU 各送 N 个」。
+     */
+    public function isAllOf(): bool
+    {
+        return (int) $this->type === Gift::TYPE_ALL;
+    }
+}

+ 44 - 0
packages/Longyi/FreeGift/src/Providers/EventServiceProvider.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Longyi\FreeGift\Providers;
+
+use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
+
+class EventServiceProvider extends ServiceProvider
+{
+    /**
+     * The event listener mappings for the package.
+     *
+     * @var array
+     */
+    protected $listen = [
+        // 与原生促销 Webkul\CartRule\Listeners\Cart@applyCartRules 并列
+        'checkout.cart.collect.totals.before' => [
+            'Longyi\FreeGift\Listeners\Cart@applyFreeGifts',
+        ],
+
+        // 用户手动删除赠品 → 打标记,避免下次又被自动加回来
+        'checkout.cart.delete.before' => [
+            'Longyi\FreeGift\Listeners\CartItemDelete@handle',
+        ],
+
+        // 禁止修改赠品数量
+        'checkout.cart.update.before' => [
+            'Longyi\FreeGift\Listeners\CartItemUpdate@handle',
+        ],
+
+        // 下单后结算赠品用量
+        'checkout.order.save.after' => [
+            'Longyi\FreeGift\Listeners\OrderPlaced@handle',
+        ],
+
+        // 后台营销规则表单:Settings 抽屉之后追加「赠品设置」抽屉
+        'bagisto.admin.marketing.promotions.cart_rules.create.card.accordion.settings.after' => [
+            'Longyi\FreeGift\Listeners\AdminCartRuleForm@giftSettings',
+        ],
+
+        'bagisto.admin.marketing.promotions.cart_rules.edit.card.accordion.settings.after' => [
+            'Longyi\FreeGift\Listeners\AdminCartRuleForm@giftSettings',
+        ],
+    ];
+}

+ 64 - 0
packages/Longyi/FreeGift/src/Providers/FreeGiftServiceProvider.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Longyi\FreeGift\Providers;
+
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\ServiceProvider;
+use Longyi\FreeGift\Http\Requests\GiftCartRuleRequest;
+use Longyi\FreeGift\Repositories\GiftCartRuleRepository;
+use Webkul\Admin\Http\Requests\CartRuleRequest;
+use Webkul\CartRule\Repositories\CartRuleRepository;
+
+class FreeGiftServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        $this->registerConfig();
+
+        // 复用 Bagisto 原生的营销规则(Marketing > Promotions > Cart Rules):
+        // 规则本身的字段不重复造,只追加 free_gift[*] 配置。
+        $this->app->bind(CartRuleRequest::class, GiftCartRuleRequest::class);
+
+        $this->app->bind(CartRuleRepository::class, GiftCartRuleRepository::class);
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
+
+        $this->loadTranslationsFrom(__DIR__.'/../Resources/lang', 'free_gift');
+
+        $this->loadViewsFrom(__DIR__.'/../Resources/views', 'free_gift');
+
+        $this->loadRoutesFrom(__DIR__.'/../Routes/shop-routes.php');
+
+        // 后台:赠品 SKU 选择器(商品搜索接口)
+        $this->loadRoutesFrom(__DIR__.'/../Routes/admin-routes.php');
+
+        $this->app->register(EventServiceProvider::class);
+
+        // 前台购物车页:赠品 Banner / 挑选弹窗
+        Event::listen('bagisto.shop.checkout.cart.header.after', function ($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('free_gift::shop.cart.gift-container');
+        });
+
+        // 前台购物车行:赠品标记
+        Event::listen('bagisto.shop.checkout.cart.item_name.after', function ($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('free_gift::shop.cart.gift-item-badge');
+        });
+    }
+
+    /**
+     * 系统配置项(System Configuration > Sales > Free Gift)。
+     */
+    protected function registerConfig(): void
+    {
+        $this->mergeConfigFrom(dirname(__DIR__).'/Config/system.php', 'core');
+    }
+}

+ 83 - 0
packages/Longyi/FreeGift/src/Repositories/FreeGiftItemRepository.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace Longyi\FreeGift\Repositories;
+
+use Webkul\Core\Eloquent\Repository;
+
+class FreeGiftItemRepository extends Repository
+{
+    /**
+     * Specify model class name.
+     */
+    public function model(): string
+    {
+        return 'Longyi\FreeGift\Models\FreeGiftItem';
+    }
+
+    /**
+     * 取某个购物车下的全部赠品状态行。
+     *
+     * @param  int  $cartId
+     * @return \Illuminate\Support\Collection
+     */
+    public function getByCart($cartId)
+    {
+        return $this->findWhere(['cart_id' => $cartId]);
+    }
+
+    /**
+     * 取一条赠品状态行。
+     *
+     * @param  int  $cartId
+     * @param  string  $sku
+     * @param  int  $ruleId
+     * @return \Longyi\FreeGift\Contracts\FreeGiftItem|null
+     */
+    public function findOne($cartId, $sku, $ruleId)
+    {
+        return $this->findOneWhere([
+            'cart_id' => $cartId,
+            'sku'     => $sku,
+            'rule_id' => $ruleId,
+        ]);
+    }
+
+    /**
+     * 购物车清空时,恢复被用户手动删除的赠品的自动加购资格。
+     *
+     * @param  int  $cartId
+     * @return void
+     */
+    public function resetDeleted($cartId)
+    {
+        $this->getModel()->where('cart_id', $cartId)->update([
+            'is_item_deleted' => 0,
+            'reserved_qty'    => 0,
+        ]);
+    }
+
+    /**
+     * 把某个赠品行标记为「用户已手动删除」。
+     *
+     * @param  int  $cartItemId
+     * @return void
+     */
+    public function markDeletedByCartItemId($cartItemId)
+    {
+        $this->getModel()->where('cart_item_id', $cartItemId)->update([
+            'is_item_deleted' => 1,
+            'reserved_qty'    => 0,
+        ]);
+    }
+
+    /**
+     * 清理某个购物车的全部赠品状态行。
+     *
+     * @param  int  $cartId
+     * @return void
+     */
+    public function deleteByCart($cartId)
+    {
+        $this->getModel()->where('cart_id', $cartId)->delete();
+    }
+}

+ 58 - 0
packages/Longyi/FreeGift/src/Repositories/FreeGiftRuleRepository.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace Longyi\FreeGift\Repositories;
+
+use Webkul\Core\Eloquent\Repository;
+
+class FreeGiftRuleRepository extends Repository
+{
+    /**
+     * Specify model class name.
+     */
+    public function model(): string
+    {
+        return 'Longyi\FreeGift\Models\FreeGiftRule';
+    }
+
+    /**
+     * 按营销规则 ID 取赠品配置。
+     *
+     * @param  int  $cartRuleId
+     * @return \Longyi\FreeGift\Contracts\FreeGiftRule|null
+     */
+    public function findByCartRuleId($cartRuleId)
+    {
+        return $this->findOneWhere(['cart_rule_id' => $cartRuleId]);
+    }
+
+    /**
+     * 批量保存赠品配置(新增或更新)。
+     *
+     * @param  int  $cartRuleId
+     * @param  array  $data
+     * @return \Longyi\FreeGift\Contracts\FreeGiftRule
+     */
+    public function saveByCartRuleId($cartRuleId, array $data)
+    {
+        $attributes = array_merge($data, ['cart_rule_id' => $cartRuleId]);
+
+        $existing = $this->findByCartRuleId($cartRuleId);
+
+        if ($existing) {
+            return $this->update($attributes, $existing->id);
+        }
+
+        return $this->create($attributes);
+    }
+
+    /**
+     * 删除某条营销规则下的赠品配置。
+     *
+     * @param  int  $cartRuleId
+     * @return void
+     */
+    public function deleteByCartRuleId($cartRuleId)
+    {
+        $this->getModel()->where('cart_rule_id', $cartRuleId)->delete();
+    }
+}

+ 146 - 0
packages/Longyi/FreeGift/src/Repositories/GiftCartRuleRepository.php

@@ -0,0 +1,146 @@
+<?php
+
+namespace Longyi\FreeGift\Repositories;
+
+use Illuminate\Container\Container;
+use Illuminate\Support\Facades\Storage;
+use Longyi\FreeGift\Config\Gift;
+use Webkul\Attribute\Repositories\AttributeFamilyRepository;
+use Webkul\Attribute\Repositories\AttributeRepository;
+use Webkul\CartRule\Repositories\CartRuleCouponRepository;
+use Webkul\CartRule\Repositories\CartRuleRepository as BaseCartRuleRepository;
+use Webkul\Category\Repositories\CategoryRepository;
+use Webkul\Core\Repositories\CountryRepository;
+use Webkul\Core\Repositories\CountryStateRepository;
+use Webkul\Tax\Repositories\TaxCategoryRepository;
+
+/**
+ * 接管原生营销规则仓库,在保存规则的同时落库/清理赠品配置。
+ *
+ * 之所以改仓库而不是改控制器:后台表单、DataGrid、API 都走同一个仓库,
+ * 改仓库能保证「任何入口保存规则」都会同步赠品配置。
+ */
+class GiftCartRuleRepository extends BaseCartRuleRepository
+{
+    public function __construct(
+        AttributeFamilyRepository $attributeFamilyRepository,
+        AttributeRepository $attributeRepository,
+        CategoryRepository $categoryRepository,
+        CartRuleCouponRepository $cartRuleCouponRepository,
+        TaxCategoryRepository $taxCategoryRepository,
+        CountryRepository $countryRepository,
+        CountryStateRepository $countryStateRepository,
+        protected FreeGiftRuleRepository $freeGiftRuleRepository,
+        Container $container
+    ) {
+        parent::__construct(
+            $attributeFamilyRepository,
+            $attributeRepository,
+            $categoryRepository,
+            $cartRuleCouponRepository,
+            $taxCategoryRepository,
+            $countryRepository,
+            $countryStateRepository,
+            $container
+        );
+    }
+
+    /**
+     * @return \Webkul\CartRule\Contracts\CartRule
+     */
+    public function create(array $data)
+    {
+        $cartRule = parent::create($data);
+
+        $this->saveGiftConfig($cartRule->id, $data);
+
+        return $cartRule;
+    }
+
+    /**
+     * @param  int  $id
+     * @return \Webkul\CartRule\Contracts\CartRule
+     */
+    public function update(array $data, $id)
+    {
+        $cartRule = parent::update($data, $id);
+
+        $this->saveGiftConfig($cartRule->id, $data);
+
+        return $cartRule;
+    }
+
+    /**
+     * 删除规则时一并清掉赠品配置(DB 层也有 cascade,这里为了兼容软删除等场景)。
+     *
+     * @param  int  $id
+     */
+    public function delete($id)
+    {
+        $this->freeGiftRuleRepository->deleteByCartRuleId($id);
+
+        return parent::delete($id);
+    }
+
+    /**
+     * 把请求里的 free_gift[*] 落库。
+     *
+     * @param  int  $cartRuleId
+     * @param  array  $data
+     */
+    protected function saveGiftConfig($cartRuleId, array $data): void
+    {
+        // 不是赠品型规则 → 清掉可能残留的旧配置
+        if (! Gift::isGiftAction($data['action_type'] ?? null)) {
+            $this->freeGiftRuleRepository->deleteByCartRuleId($cartRuleId);
+
+            return;
+        }
+
+        $freeGift = $data['free_gift'] ?? [];
+
+        $this->freeGiftRuleRepository->saveByCartRuleId($cartRuleId, [
+            'sku'                 => $freeGift['sku'] ?? null,
+            'type'                => (int) ($freeGift['type'] ?? Gift::TYPE_ALL),
+            'items_discount'      => $freeGift['items_discount'] ?? '100%',
+            'minimal_items_price' => ($freeGift['minimal_items_price'] ?? null) ?: null,
+            'apply_tax'           => (int) ($freeGift['apply_tax'] ?? 1),
+            'apply_shipping'      => (int) ($freeGift['apply_shipping'] ?? 0),
+            'auto_add'            => (int) ($freeGift['auto_add'] ?? 1),
+            'show_banner'         => (int) ($freeGift['show_banner'] ?? 0),
+            'banner_image'        => $this->resolveBannerImage($cartRuleId, $freeGift) ?: null,
+            'banner_alt'          => $freeGift['banner_alt'] ?? null,
+        ]);
+    }
+
+    /**
+     * 解析 Banner 图片路径。
+     *
+     * 表单里没有单独的 banner_image 字段(只有文件上传),所以未上传新图时必须
+     * 保留旧值,否则每次保存规则都会把已上传的图片清掉。
+     *
+     * @param  int  $cartRuleId
+     * @param  array  $freeGift
+     * @return string|null
+     */
+    protected function resolveBannerImage($cartRuleId, array $freeGift)
+    {
+        if (! request()->hasFile('free_gift_banner_image_file')) {
+            if (! empty($freeGift['banner_image'])) {
+                return $freeGift['banner_image'];
+            }
+
+            return $this->freeGiftRuleRepository->findByCartRuleId($cartRuleId)?->banner_image;
+        }
+
+        $path = request()->file('free_gift_banner_image_file')->store('free-gift');
+
+        $oldPath = $this->freeGiftRuleRepository->findByCartRuleId($cartRuleId)?->banner_image;
+
+        if ($oldPath && $oldPath !== $path) {
+            Storage::delete($oldPath);
+        }
+
+        return $path;
+    }
+}

+ 60 - 0
packages/Longyi/FreeGift/src/Resources/lang/en/admin.php

@@ -0,0 +1,60 @@
+<?php
+
+return [
+    'gift-settings'            => 'Free Gift Settings',
+    'gift-settings-info'       => 'Configured only when the action type is one of the free gift types.',
+    'sku'                      => 'Gift SKUs',
+    'sku-placeholder'          => 'e.g. gift-a, gift-b',
+    'sku-info'                 => 'Comma separated product SKUs. Leave empty for "buy it get it" to give the purchased product itself.',
+    'type'                     => 'Gift list type',
+    'type-all'                 => 'Each SKU gets the full quantity',
+    'type-one'                 => 'All SKUs share the quantity (customer picks)',
+    'items-discount'           => 'Gift discount',
+    'items-discount-info'      => '100% (fully free), 50%, -10 (off 10) or a fixed price like 99.',
+    'minimal-items-price'      => 'Minimum price after discount',
+    'minimal-items-price-info' => 'The gift price will never go below this value. Leave empty for no limit.',
+    'auto-add'                 => 'Add gifts to cart automatically',
+    'auto-add-info'            => 'If disabled, customers pick gifts manually from the cart page.',
+    'show-banner'              => 'Show banner in cart',
+    'banner-alt'               => 'Banner text',
+    'banner-image'             => 'Banner image',
+    'banner-image-info'        => 'Used by the cart page banner of this rule. JPG / PNG / WEBP, up to 2 MB.',
+    'yes'                      => 'Yes',
+    'no'                       => 'No',
+
+    // 赠品 SKU 选择器(弹窗)
+    'choose-products'          => 'Choose Products',
+    'picker-title'             => 'Choose gift products',
+    'picker-close'             => 'Close',
+    'picker-search-placeholder' => 'Search by product name or SKU, empty for all',
+    'picker-loading'           => 'Loading…',
+    'picker-empty'             => 'No products found',
+    'picker-no-name'           => '(Unnamed product)',
+    'picker-load-more'         => 'Load more',
+    'picker-not-giftable'      => 'Cannot be given as a gift',
+    'picker-confirm'           => 'Confirm',
+    'picker-cancel'            => 'Cancel',
+    'picker-selected'          => 'Selected :count: :skus',
+
+    'action-same-product'  => 'Free Gift: Buy one get one (same product)',
+    'action-with-products' => 'Free Gift: With selected products',
+    'action-each-n'        => 'Free Gift: Every N items',
+    'action-whole-cart'    => 'Free Gift: Whole cart',
+    'action-spent'         => 'Free Gift: Spent amount',
+
+    'hint-same-product'  => '"Discount Amount" is the quantity of the same product given for free, "Discount Step" is how many items the customer must buy.',
+    'hint-with-products' => '"Discount Amount" is the quantity of the gift SKUs given away once any product of the conditions is in the cart.',
+    'hint-each-n'        => '"Discount Step" is the number of items required, "Discount Amount" is the number of gifts given for every step.',
+    'hint-whole-cart'    => '"Discount Amount" is the quantity of the gift SKUs given away, no condition is required.',
+    'hint-spent'         => '"Discount Step" is the amount (in base currency) required, "Discount Amount" is the number of gifts given for every step.',
+
+    // 系统配置 → 销售 → Free Gift
+    'system' => [
+        'title'            => 'Free Gift',
+        'info'             => 'Global settings for free gift (giveaway) promotions.',
+        'general'          => 'General',
+        'general-info'     => 'Global switches only. Each promotion still configures its own gifts in Marketing → Promotions → Cart Rules.',
+        'disable-auto-add' => 'Disable adding free gifts to the cart automatically',
+        'show-banner'      => 'Show the free gift banner on the cart page',
+    ],
+];

+ 24 - 0
packages/Longyi/FreeGift/src/Resources/lang/en/app.php

@@ -0,0 +1,24 @@
+<?php
+
+return [
+    'cart' => [
+        'gift-quantity-locked' => 'The quantity of free gift items cannot be changed. Please adjust the quantity of regular items instead.',
+        'gift'                 => 'Free Gift',
+        'gift-note'            => 'Free gift added automatically. It is not counted in the payable amount.',
+        'gift-original-price'  => 'Original price',
+        'gift-select-title'    => 'Choose your free gift',
+        'gift-select-hint'     => 'You can pick up to :qty item(s) from the list below.',
+        'gift-add'             => 'Add to cart',
+        'gift-added'           => 'Free gift added successfully.',
+        'gift-removed'         => 'Free gift removed.',
+        'gift-unavailable'     => 'The selected free gift is currently unavailable.',
+        'gift-rule-inactive'   => 'This free gift rule is no longer valid.',
+        'gift-pick-button'     => 'Choose gift',
+        'gift-strip'           => 'You have free gift(s) available from this promotion.',
+        'gift-free-price'      => 'FREE',
+        'gift-remove'          => 'Remove',
+        'gift-close'           => 'Close',
+        'gift-error'           => 'Unable to update the free gift, please try again.',
+        'gift-limit-reached'   => 'All the free gifts of this promotion have been added.',
+    ],
+];

+ 60 - 0
packages/Longyi/FreeGift/src/Resources/lang/zh_CN/admin.php

@@ -0,0 +1,60 @@
+<?php
+
+return [
+    'gift-settings'            => '赠品设置',
+    'gift-settings-info'       => '仅当动作类型选择赠品类时才生效。',
+    'sku'                      => '赠品 SKU',
+    'sku-placeholder'          => '例如:gift-a, gift-b',
+    'sku-info'                 => '多个 SKU 用英文逗号分隔。「买它送它」留空表示赠送所购商品本身。',
+    'type'                     => '赠品组类型',
+    'type-all'                 => '每个 SKU 各送指定数量',
+    'type-one'                 => '所有 SKU 合计送指定数量(用户自选)',
+    'items-discount'           => '赠品折扣',
+    'items-discount-info'      => '100% 表示全免;50% 表示五折;-10 表示立减 10;也可以直接填固定价,例如 99。',
+    'minimal-items-price'      => '折后最低价',
+    'minimal-items-price-info' => '赠品折扣后的价格不会低于该值,留空表示不限。',
+    'auto-add'                 => '自动加入购物车',
+    'auto-add-info'            => '关闭后由用户在购物车页面手动挑选赠品。',
+    'show-banner'              => '购物车显示 Banner',
+    'banner-alt'               => 'Banner 文案',
+    'banner-image'             => 'Banner 图片',
+    'banner-image-info'        => '该规则的购物车 Banner 图片。支持 JPG / PNG / WEBP,最大 2 MB。',
+    'yes'                      => '是',
+    'no'                       => '否',
+
+    // 赠品 SKU 选择器(弹窗)
+    'choose-products'          => '选择商品',
+    'picker-title'             => '选择赠品商品',
+    'picker-close'             => '关闭',
+    'picker-search-placeholder' => '输入商品名称或 SKU 搜索,留空显示全部',
+    'picker-loading'           => '加载中…',
+    'picker-empty'             => '没有找到商品',
+    'picker-no-name'           => '(未命名商品)',
+    'picker-load-more'         => '加载更多',
+    'picker-not-giftable'      => '不可作为赠品',
+    'picker-confirm'           => '确定',
+    'picker-cancel'            => '取消',
+    'picker-selected'          => '已选 :count 个::skus',
+
+    'action-same-product'  => '赠品:买它送它(同款买赠)',
+    'action-with-products' => '赠品:购买指定商品送',
+    'action-each-n'        => '赠品:每 N 件送',
+    'action-whole-cart'    => '赠品:整单送',
+    'action-spent'         => '赠品:满额送',
+
+    'hint-same-product'  => '「折扣数量」为免费赠送的同款数量,「折扣步长」为需要购买的数量。',
+    'hint-with-products' => '「折扣数量」为购物车出现条件商品后一次性赠送的赠品数量。',
+    'hint-each-n'        => '「折扣步长」为需要的商品件数,「折扣数量」为每达标一次赠送的数量。',
+    'hint-whole-cart'    => '「折扣数量」为赠送的赠品数量,无需配置条件。',
+    'hint-spent'         => '「折扣步长」为需要的金额(基础货币),「折扣数量」为每达标一次赠送的数量。',
+
+    // 系统配置 → 销售 → 赠品活动
+    'system' => [
+        'title'            => '赠品活动',
+        'info'             => '赠品(免费赠送)相关的全局设置。',
+        'general'          => '通用设置',
+        'general-info'     => '这里只是全局开关;具体每个活动的赠品规则在「营销 → 促销 → 购物车规则」里配置。',
+        'disable-auto-add' => '关闭「自动把赠品加入购物车」',
+        'show-banner'      => '在购物车页面显示赠品 Banner',
+    ],
+];

+ 24 - 0
packages/Longyi/FreeGift/src/Resources/lang/zh_CN/app.php

@@ -0,0 +1,24 @@
+<?php
+
+return [
+    'cart' => [
+        'gift-quantity-locked' => '赠品数量不可修改,请调整普通商品的数量。',
+        'gift'                 => '赠品',
+        'gift-note'            => '赠品由活动自动加入,不计入应付金额。',
+        'gift-original-price'  => '原价',
+        'gift-select-title'    => '请选择赠品',
+        'gift-select-hint'     => '您最多可以从以下列表中挑选 :qty 件赠品。',
+        'gift-add'             => '加入购物车',
+        'gift-added'           => '赠品已加入购物车。',
+        'gift-removed'         => '赠品已移除。',
+        'gift-unavailable'     => '所选赠品当前不可用。',
+        'gift-rule-inactive'   => '该赠品活动已失效。',
+        'gift-pick-button'     => '挑选赠品',
+        'gift-strip'           => '您有可挑选的赠品名额。',
+        'gift-free-price'      => '免费',
+        'gift-remove'          => '移除',
+        'gift-close'           => '关闭',
+        'gift-error'           => '赠品更新失败,请稍后重试。',
+        'gift-limit-reached'   => '该活动的赠品名额已全部领取。',
+    ],
+];

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1244 - 0
packages/Longyi/FreeGift/src/Resources/views/admin/cart-rules/gift-settings.blade.php


+ 342 - 0
packages/Longyi/FreeGift/src/Resources/views/shop/cart/gift-container.blade.php

@@ -0,0 +1,342 @@
+{!! view_render_event('bagisto.shop.checkout.cart.gift_container.before') !!}
+
+{{--
+    购物车页赠品区域:Banner + 赠品挑选弹窗。
+
+    整块由 v-free-gift 组件渲染,数据来自 free_gift::shop-routes 里的
+    api/free-gift/candidates,逻辑上与 Amasty 的 Cart Popup 一致。
+--}}
+<v-free-gift></v-free-gift>
+
+{!! view_render_event('bagisto.shop.checkout.cart.gift_container.after') !!}
+
+@pushOnce('scripts')
+    <script
+        type="text/x-template"
+        id="v-free-gift-template"
+    >
+        <div v-if="groups.length" class="container px-[60px] pt-6 max-lg:px-8 max-md:px-4">
+            <!-- 规则 Banner -->
+            <div
+                v-for="banner in banners"
+                :key="'banner-' + banner.rule_id"
+                class="mb-3 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-zinc-200 bg-white px-5 py-4 max-md:px-4 max-md:py-3"
+            >
+                <div class="flex items-center gap-4">
+                    <img
+                        v-if="banner.image"
+                        :src="banner.image"
+                        class="h-16 w-24 rounded-xl object-cover max-md:h-12 max-md:w-16"
+                        alt=""
+                    />
+
+                    <div>
+                        <p class="text-base font-medium text-zinc-800 max-md:text-sm">
+                            @{{ banner.text }}
+                        </p>
+
+                        <p v-if="banner.remaining_qty > 0" class="mt-0.5 text-xs text-zinc-500">
+                            @{{ hintFor(banner.rule_id) }}
+                        </p>
+                    </div>
+                </div>
+
+                <button
+                    v-if="banner.remaining_qty > 0"
+                    type="button"
+                    class="primary-button rounded-2xl px-6 py-2.5 text-sm max-md:px-4 max-md:py-2"
+                    @click="openPicker(banner.rule_id)"
+                >
+                    @lang('free_gift::app.cart.gift-pick-button')
+                </button>
+
+                <span
+                    v-else
+                    class="text-sm font-medium text-green-600"
+                >
+                    @lang('free_gift::app.cart.gift-added')
+                </span>
+            </div>
+
+            <!-- 没有 Banner 但还有剩余名额(手动模式 / 用户删掉了自动赠品) -->
+            <div
+                v-for="group in manualGroups"
+                :key="'group-' + group.rule_id"
+                class="mb-3 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-dashed border-navyBlue/40 bg-blue-50/40 px-5 py-3.5 max-md:px-4 max-md:py-3"
+            >
+                <div>
+                    <p class="text-sm font-medium text-zinc-800">
+                        @lang('free_gift::app.cart.gift-strip')
+                        <span v-if="group.rule_name">(@{{ group.rule_name }})</span>
+                    </p>
+                </div>
+
+                <button
+                    type="button"
+                    class="secondary-button rounded-2xl px-5 py-2 text-sm"
+                    @click="openPicker(group.rule_id)"
+                >
+                    @lang('free_gift::app.cart.gift-pick-button')
+                </button>
+            </div>
+        </div>
+
+        <!-- 赠品挑选弹窗 -->
+        <div
+            v-if="activeGroup"
+            class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
+        >
+            <div class="w-full max-w-2xl overflow-hidden rounded-2xl bg-white shadow-2xl">
+                <div class="flex items-start justify-between gap-4 border-b border-zinc-200 px-5 py-4">
+                    <div>
+                        <p class="text-lg font-semibold text-zinc-800">
+                            @lang('free_gift::app.cart.gift-select-title')
+                        </p>
+
+                        <p class="mt-1 text-xs text-zinc-500">
+                            @{{ hintFor(activeGroup.rule_id) }}
+                        </p>
+                    </div>
+
+                    <span
+                        class="cursor-pointer text-2xl leading-none text-zinc-400 hover:text-zinc-700"
+                        @click="closePicker"
+                    >
+                        &times;
+                    </span>
+                </div>
+
+                <div class="max-h-[60vh] overflow-y-auto px-5 py-4">
+                    <p
+                        v-if="errorMessage"
+                        class="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600"
+                    >
+                        @{{ errorMessage }}
+                    </p>
+
+                    <div
+                        v-for="gift in activeGroup.gifts"
+                        :key="'gift-' + gift.sku"
+                        class="flex items-center gap-4 border-b border-zinc-100 py-3 last:border-b-0"
+                    >
+                        <img
+                            :src="gift.image"
+                            class="h-20 w-20 shrink-0 rounded-xl border border-zinc-100 object-cover"
+                            alt=""
+                        />
+
+                        <div class="min-w-0 flex-1">
+                            <a
+                                :href="gift.url"
+                                target="_blank"
+                                class="text-sm font-medium text-zinc-800 hover:text-navyBlue"
+                            >
+                                @{{ gift.name }}
+                            </a>
+
+                            <p class="mt-1 text-xs text-zinc-500">
+                                <span class="line-through">@{{ gift.price }}</span>
+
+                                <span class="ml-2 font-semibold text-green-600">
+                                    @lang('free_gift::app.cart.gift-free-price')
+                                </span>
+                            </p>
+
+                            <p
+                                v-if="activeGroup.type === 1 && activeGroup.remaining_qty <= 0 && gift.reserved_qty <= 0"
+                                class="mt-1 text-xs text-zinc-400"
+                            >
+                                @lang('free_gift::app.cart.gift-limit-reached')
+                            </p>
+                        </div>
+
+                        <div class="flex shrink-0 items-center gap-3">
+                            <template v-if="gift.reserved_qty > 0">
+                                <span class="text-sm font-medium text-zinc-700">
+                                    x@{{ gift.reserved_qty }}
+                                </span>
+
+                                <button
+                                    type="button"
+                                    class="cursor-pointer text-sm font-medium text-blue-700 hover:underline"
+                                    @click="removeGift(gift)"
+                                >
+                                    @lang('free_gift::app.cart.gift-remove')
+                                </button>
+                            </template>
+
+                            <button
+                                v-else-if="canAdd(gift)"
+                                type="button"
+                                class="secondary-button rounded-2xl px-5 py-2 text-sm"
+                                @click="addGift(gift)"
+                            >
+                                @lang('free_gift::app.cart.gift-add')
+                            </button>
+
+                            <span v-else class="text-xs text-zinc-400">
+                                @lang('free_gift::app.cart.gift-unavailable')
+                            </span>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="flex justify-end border-t border-zinc-200 px-5 py-3">
+                    <button
+                        type="button"
+                        class="secondary-button rounded-2xl px-6 py-2 text-sm"
+                        @click="closePicker"
+                    >
+                        @lang('free_gift::app.cart.gift-close')
+                    </button>
+                </div>
+            </div>
+        </div>
+    </script>
+
+    <script type="module">
+        app.component('v-free-gift', {
+            template: '#v-free-gift-template',
+
+            data() {
+                return {
+                    groups: [],
+
+                    banners: [],
+
+                    activeRuleId: null,
+
+                    errorMessage: '',
+                };
+            },
+
+            computed: {
+                activeGroup() {
+                    return this.groups.find(group => group.rule_id === this.activeRuleId) || null;
+                },
+
+                /**
+                 * 没有 Banner 可点、但仍有剩余名额的规则。
+                 */
+                manualGroups() {
+                    return this.groups.filter(group => {
+                        if (group.remaining_qty <= 0) {
+                            return false;
+                        }
+
+                        return ! this.banners.some(banner => banner.rule_id === group.rule_id);
+                    });
+                },
+            },
+
+            mounted() {
+                this.load();
+            },
+
+            methods: {
+                /**
+                 * 拉取当前购物车的赠品候选。
+                 */
+                load() {
+                    this.$axios.get('{{ route('shop.free_gift.candidates') }}')
+                        .then(response => {
+                            const data = response.data.data ?? {};
+
+                            this.groups = data.groups ?? [];
+
+                            this.banners = data.banners ?? [];
+                        })
+                        .catch(() => {
+                            this.groups = [];
+
+                            this.banners = [];
+                        });
+                },
+
+                /**
+                 * 提示文案里的 :qty 用当前规则的剩余名额替换。
+                 */
+                hintFor(ruleId) {
+                    const group = this.groups.find(item => item.rule_id === ruleId);
+
+                    const qty = group
+                        ? (group.type === 1 ? group.remaining_qty : group.allowed_qty)
+                        : 0;
+
+                    return @json(trans('free_gift::app.cart.gift-select-hint')).replace(':qty', qty);
+                },
+
+                /**
+                 * 「每个 SKU 各送 N 个」只要该行还有名额就能继续加;
+                 * 「合计送 N 个」要整组还有名额。
+                 */
+                canAdd(gift) {
+                    if (gift.remaining_qty <= 0) {
+                        return false;
+                    }
+
+                    if (! this.activeGroup) {
+                        return false;
+                    }
+
+                    return this.activeGroup.type === 0 || this.activeGroup.remaining_qty > 0;
+                },
+
+                openPicker(ruleId) {
+                    this.errorMessage = '';
+
+                    this.activeRuleId = ruleId;
+                },
+
+                closePicker() {
+                    this.errorMessage = '';
+
+                    this.activeRuleId = null;
+                },
+
+                /**
+                 * 加入赠品:合计送 N 个的整组名额一次用完,逐 SKU 送的按剩余量补齐。
+                 */
+                addGift(gift) {
+                    const group = this.activeGroup;
+
+                    const qty = group && group.type === 0
+                        ? gift.remaining_qty
+                        : 1;
+
+                    this.submit('{{ route('shop.free_gift.add') }}', {
+                        sku:     gift.sku,
+                        rule_id: group ? group.rule_id : null,
+                        qty:     qty,
+                    });
+                },
+
+                removeGift(gift) {
+                    if (! gift.cart_item_id) {
+                        return;
+                    }
+
+                    this.submit('{{ route('shop.free_gift.remove') }}', {
+                        cart_item_id: gift.cart_item_id,
+                    });
+                },
+
+                /**
+                 * 赠品变动会影响小计/优惠,直接重载购物车页换取一致的金额展示。
+                 */
+                submit(url, payload) {
+                    this.errorMessage = '';
+
+                    this.$axios.post(url, payload)
+                        .then(() => {
+                            window.location.reload();
+                        })
+                        .catch(error => {
+                            this.errorMessage = error.response?.data?.message
+                                ?? @json(trans('free_gift::app.cart.gift-error'));
+                        });
+                },
+            },
+        });
+    </script>
+@endPushOnce

+ 18 - 0
packages/Longyi/FreeGift/src/Resources/views/shop/cart/gift-item-badge.blade.php

@@ -0,0 +1,18 @@
+{{--
+    购物车行内的赠品标记。
+
+    注入点 bagisto.shop.checkout.cart.item_name.after 位于 v-cart 组件模板内,
+    所以这里可以直接用 item(来自 CartItemResource 的 is_free_gift 字段)。
+--}}
+<div
+    v-if="item.is_free_gift"
+    class="mt-1.5 flex flex-wrap items-center gap-2"
+>
+    <span class="rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-600">
+        @lang('free_gift::app.cart.gift')
+    </span>
+
+    <span class="text-xs text-zinc-500">
+        @lang('free_gift::app.cart.gift-note')
+    </span>
+</div>

+ 15 - 0
packages/Longyi/FreeGift/src/Routes/admin-routes.php

@@ -0,0 +1,15 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\FreeGift\Http\Controllers\Admin\ProductPickerController;
+
+/**
+ * 后台赠品相关路由(沿用 Longyi\Member / Longyi\Gift 的写法:
+ * 自带 web + admin 中间件组,prefix 自己写全)。
+ */
+Route::group(['middleware' => ['web', 'admin'], 'prefix' => 'admin/free-gift'], function () {
+    /**
+     * 赠品 SKU 选择器的数据源(只读,返回 JSON)。
+     */
+    Route::get('products', [ProductPickerController::class, 'index'])->name('admin.free_gift.products');
+});

+ 21 - 0
packages/Longyi/FreeGift/src/Routes/shop-routes.php

@@ -0,0 +1,21 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\FreeGift\Http\Controllers\Shop\GiftController;
+
+/**
+ * 前台赠品接口。
+ *
+ * 用和 Webkul\Shop 一样的 ['web', 'shop'] 中间件组,
+ * 保证拿到 Theme / Locale / Currency 和 session 里的购物车。
+ */
+Route::middleware(['web', 'shop'])->prefix('api/free-gift')->group(function () {
+    Route::get('candidates', [GiftController::class, 'candidates'])
+        ->name('shop.free_gift.candidates');
+
+    Route::post('add', [GiftController::class, 'add'])
+        ->name('shop.free_gift.add');
+
+    Route::post('remove', [GiftController::class, 'remove'])
+        ->name('shop.free_gift.remove');
+});

+ 131 - 0
packages/Longyi/FreeGift/src/Services/Actions/AbstractGiftAction.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+use Longyi\FreeGift\Contracts\FreeGiftRule as FreeGiftRuleContract;
+
+/**
+ * 赠品数量算法基类。
+ *
+ * 对应 Amasty 的 Model\Rule\Action\Discount\AbstractAction。
+ *
+ * 输入是「命中规则条件的非赠品购物车行」,输出是 sku => 数量 的映射,
+ * 由 GiftProcessor 交给 GiftItemsGroup 注册。
+ */
+abstract class AbstractGiftAction
+{
+    /**
+     * 计算本条规则应该送出哪些 SKU、各多少个。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     * @param  array  $items  命中条件(且非赠品)的购物车行
+     * @param  FreeGiftRuleContract  $giftRule
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     * @return array<string, float>
+     */
+    public function getGiftQtyMap($rule, array $items, FreeGiftRuleContract $giftRule, $cart): array
+    {
+        if (empty($items)) {
+            return [];
+        }
+
+        $qty = $this->limit($rule, $this->calculate($rule, $items, $cart));
+
+        if ($qty < 1) {
+            return [];
+        }
+
+        return $this->mapToSkus($giftRule, $qty, $items);
+    }
+
+    /**
+     * 计算本条规则允许赠送的总数量。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     * @param  array  $items
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    abstract protected function calculate($rule, array $items, $cart): float;
+
+    /**
+     * 把总量摊到具体 SKU 上(默认给每个配置的 SKU 都发这么多)。
+     *
+     * @return array<string, float>
+     */
+    protected function mapToSkus(FreeGiftRuleContract $giftRule, float $qty, array $items): array
+    {
+        $skus = $giftRule->skuList();
+
+        if (empty($skus)) {
+            return [];
+        }
+
+        return array_fill_keys($skus, $qty);
+    }
+
+    /**
+     * 「每满 N」的步长。
+     *
+     * 注意 cart_rules.discount_step 是 string 类型,必须显式转 float。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     */
+    protected function getStep($rule): float
+    {
+        return max(1, (float) ($rule->discount_step ?: 1));
+    }
+
+    /**
+     * 每满一个步长赠送的数量。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     */
+    protected function getAmount($rule): float
+    {
+        return max(1, (float) ($rule->discount_amount ?: 1));
+    }
+
+    /**
+     * 上限裁剪:cart_rules.discount_quantity。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     */
+    protected function limit($rule, float $qty): float
+    {
+        $cap = (int) $rule->discount_quantity;
+
+        if ($cap > 0) {
+            $qty = min($qty, $cap);
+        }
+
+        return max(0, floor($qty));
+    }
+
+    /**
+     * 命中商品的数量合计。
+     */
+    protected function totalQty(array $items): float
+    {
+        $total = 0;
+
+        foreach ($items as $item) {
+            $total += (float) $item->quantity;
+        }
+
+        return $total;
+    }
+
+    /**
+     * 命中商品的金额合计(不含税,并扣掉已分摊的促销折扣)。
+     */
+    protected function totalAmount(array $items): float
+    {
+        $total = 0;
+
+        foreach ($items as $item) {
+            $total += (float) $item->base_total - (float) $item->base_discount_amount;
+        }
+
+        return $total;
+    }
+}

+ 22 - 0
packages/Longyi/FreeGift/src/Services/Actions/CartAction.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+/**
+ * 整车送。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\Cart。
+ *
+ * 条件只作为开关(通常留空,即「车里有东西就送」),
+ * 数量直接取 discount_amount,不看件数也不看金额。
+ */
+class CartAction extends AbstractGiftAction
+{
+    /**
+     * 命中即送固定数量。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        return $this->getAmount($rule);
+    }
+}

+ 38 - 0
packages/Longyi/FreeGift/src/Services/Actions/EachNAction.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+use Longyi\FreeGift\Config\Gift;
+
+/**
+ * 每 N 件送。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\Eachn。
+ *
+ * 与 ProductAction 的区别:条件是「触发开关」,数量按**整个购物车**的非赠品总件数计算,
+ * 而不是只看命中条件的商品。例如「车里至少有 1 件 A 商品时,每买 3 件送 1 件」。
+ */
+class EachNAction extends AbstractGiftAction
+{
+    /**
+     * 以全车非赠品件数为基数。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        $cartQty = 0;
+
+        foreach ($cart->all_items as $cartItem) {
+            if ($cartItem->additional[Gift::CART_ITEM_FLAG] ?? null) {
+                continue;
+            }
+
+            $cartQty += (float) $cartItem->quantity;
+        }
+
+        return floor($cartQty / $step) * $amount;
+    }
+}

+ 40 - 0
packages/Longyi/FreeGift/src/Services/Actions/GiftActionPool.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+use Longyi\FreeGift\Config\Gift;
+
+/**
+ * action_type → 算法 的分发器。
+ */
+class GiftActionPool
+{
+    /**
+     * @var array<string, AbstractGiftAction>
+     */
+    protected array $actions;
+
+    public function __construct(
+        SameProductAction $sameProduct,
+        ProductAction $product,
+        EachNAction $eachN,
+        CartAction $cart,
+        SpentAction $spent
+    ) {
+        $this->actions = [
+            Gift::ACTION_SAME_PRODUCT  => $sameProduct,
+            Gift::ACTION_WITH_PRODUCTS => $product,
+            Gift::ACTION_EACH_N        => $eachN,
+            Gift::ACTION_WHOLE_CART    => $cart,
+            Gift::ACTION_SPENT         => $spent,
+        ];
+    }
+
+    /**
+     * 取 action_type 对应的算法。
+     */
+    public function get($actionType): ?AbstractGiftAction
+    {
+        return $this->actions[$actionType] ?? null;
+    }
+}

+ 26 - 0
packages/Longyi/FreeGift/src/Services/Actions/ProductAction.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+/**
+ * 买指定商品送。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\Product。
+ *
+ * 条件筛选出的商品合计每满 discount_step 件,送 discount_amount 个赠品;
+ * 总额以 discount_quantity 封顶。
+ */
+class ProductAction extends AbstractGiftAction
+{
+    /**
+     * 命中商品合计数量每满一个步长,送一份。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        return floor($this->totalQty($items) / $step) * $amount;
+    }
+}

+ 69 - 0
packages/Longyi/FreeGift/src/Services/Actions/SameProductAction.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+use Longyi\FreeGift\Contracts\FreeGiftRule as FreeGiftRuleContract;
+
+/**
+ * 买它送它。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\SameProduct。
+ *
+ * 两种用法:
+ *  1. 配了赠品 SKU → 按「命中商品总件数」算总量,发给配置的 SKU
+ *  2. 没配赠品 SKU → 送的就是命中的商品本身,每个商品各自算各自的量
+ */
+class SameProductAction extends AbstractGiftAction
+{
+    /**
+     * 计算允许赠送的总量(每个命中商品各自算,再求和)。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        $total = 0;
+
+        foreach ($items as $item) {
+            $total += floor(((float) $item->quantity) / $step) * $amount;
+        }
+
+        return $total;
+    }
+
+    /**
+     * 未配置赠品 SKU 时走「送商品本身」的分支。
+     *
+     * @return array<string, float>
+     */
+    public function getGiftQtyMap($rule, array $items, FreeGiftRuleContract $giftRule, $cart): array
+    {
+        if (! empty($giftRule->skuList())) {
+            return parent::getGiftQtyMap($rule, $items, $giftRule, $cart);
+        }
+
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        $cap = (int) $rule->discount_quantity;
+
+        $map = [];
+
+        foreach ($items as $item) {
+            $qty = floor(((float) $item->quantity) / $step) * $amount;
+
+            if ($cap > 0) {
+                $qty = min($qty, $cap);
+            }
+
+            if ($qty >= 1) {
+                $map[$item->sku] = ($map[$item->sku] ?? 0) + $qty;
+            }
+        }
+
+        return $map;
+    }
+}

+ 27 - 0
packages/Longyi/FreeGift/src/Services/Actions/SpentAction.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+/**
+ * 满额送。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\Spent。
+ *
+ * 命中条件的商品金额合计每满 discount_step,送 discount_amount 个赠品。
+ * 金额口径:base_total - base_discount_amount(不含税,且已扣掉原生促销折扣),
+ * 与 Bagisto 原生 by_percent 规则的口径保持一致。
+ */
+class SpentAction extends AbstractGiftAction
+{
+    /**
+     * 命中商品金额每满一个步长,送一份。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        return floor($this->totalAmount($items) / $step) * $amount;
+    }
+}

+ 77 - 0
packages/Longyi/FreeGift/src/Services/GiftDiscountCalculator.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+/**
+ * 赠品折扣计算。
+ *
+ * 对应 Amasty 的 Model\DiscountCalculator,保持同样的 4 种写法:
+ *  - ''        → 全免(等同 100%)
+ *  - '100%'    → 全免
+ *  - '50%'     → 半价
+ *  - '-10'     → 立减 10
+ *  - '99'      → 一口价 99(原价 - 99)
+ */
+class GiftDiscountCalculator
+{
+    /**
+     * 计算减免金额。
+     *
+     * @param  string|null  $promo
+     * @param  float  $price
+     */
+    public function discountFor($promo, float $price): float
+    {
+        $promo = trim((string) $promo);
+
+        if ($promo === '' || $promo === '100%') {
+            return $price;
+        }
+
+        if (str_contains($promo, '%')) {
+            $percent = (float) $promo;
+
+            // 支持 "0.5" 与 "50" 两种百分比写法
+            $percent = $percent > 1 ? $percent / 100 : $percent;
+
+            return $price * min(1, max(0, $percent));
+        }
+
+        if (str_starts_with($promo, '-')) {
+            return min(abs((float) $promo), $price);
+        }
+
+        // 一口价
+        return max(0, $price - (float) $promo);
+    }
+
+    /**
+     * minimal_items_price 兜底:折后单价不得低于最低价。
+     */
+    public function clampToMinimal(float $discount, float $price, $minimal): float
+    {
+        $minimal = (float) $minimal;
+
+        if ($minimal > 0 && ($price - $discount) < $minimal) {
+            return max(0, $price - $minimal);
+        }
+
+        return $discount;
+    }
+
+    /**
+     * 一步算出赠品的实际单价。
+     *
+     * @param  string|null  $promo
+     * @param  float  $price
+     * @param  float|null  $minimal
+     */
+    public function priceFor($promo, float $price, $minimal = null): float
+    {
+        $discount = $this->discountFor($promo, $price);
+
+        $discount = $this->clampToMinimal($discount, $price, $minimal);
+
+        return round(max(0, $price - $discount), 4);
+    }
+}

+ 334 - 0
packages/Longyi/FreeGift/src/Services/GiftItem.php

@@ -0,0 +1,334 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+use Longyi\FreeGift\Config\Gift;
+
+/**
+ * 单个赠品候选(购物车 + SKU + 规则)。
+ *
+ * 对应 Amasty 的 ItemRegistry\PromoItemData。
+ *
+ * 数量口径说明:
+ *  - qty         本轮新注册进来的增量
+ *  - allowedQty  本轮累计允许赠送的总量(规则重算一次就重置一次)
+ *  - storedQty   上一轮持久化到 free_gift_items 的 allowed_qty,用来判断"是变多还是变少"
+ *  - reservedQty 已经真实躺在 cart_items 里的数量
+ *  - qtyToProcess = allowedQty - reservedQty(还差几个要补)
+ */
+class GiftItem
+{
+    /**
+     * free_gift_items.id(首次注册时为 null)。
+     */
+    protected ?int $id = null;
+
+    /**
+     * 赠品的 product.id,注册时可能还没查到。
+     */
+    protected ?int $productId = null;
+
+    /**
+     * 本轮注册的增量。
+     */
+    protected float $qty = 0;
+
+    /**
+     * 累计允许赠送的总量。
+     */
+    protected float $allowedQty = 0;
+
+    /**
+     * 已加入购物车的数量。
+     */
+    protected float $reservedQty = 0;
+
+    /**
+     * 上一轮持久化的允许量。
+     */
+    protected float $storedQty = 0;
+
+    /**
+     * 该赠品行对应的购物车行 ID。
+     */
+    protected ?int $cartItemId = null;
+
+    /**
+     * 赠品折后最低价(free_gift_rules.minimal_items_price)。
+     */
+    protected ?float $minimalPrice = null;
+
+    /**
+     * 用户是否手动删除过。
+     */
+    protected bool $deleted = false;
+
+    /**
+     * 是否自动加购。
+     */
+    protected bool $autoAdd = true;
+
+    /**
+     * 排序(按规则 sort_order 递增),保证自动加购顺序稳定。
+     */
+    protected int $sortOrder = 0;
+
+    /**
+     * 本轮的"实际要处理数量"。
+     *
+     * 「一组合计送 N 个」的规则里,同组 SKU 共享名额,需要由分组统一裁剪,
+     * 这时会显式 setProcessQty();为 null 时退化成 allowedQty - reservedQty。
+     */
+    protected ?float $processQty = null;
+
+    public function __construct(
+        protected string $sku,
+        protected int $ruleId,
+        protected ?string $discount = null,
+        protected int $ruleType = Gift::TYPE_ALL,
+        ?bool $autoAdd = true,
+    ) {
+        $this->autoAdd = $autoAdd !== false;
+    }
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function setId(?int $id): self
+    {
+        $this->id = $id;
+
+        return $this;
+    }
+
+    public function getSku(): string
+    {
+        return $this->sku;
+    }
+
+    public function getRuleId(): int
+    {
+        return $this->ruleId;
+    }
+
+    public function getDiscount(): ?string
+    {
+        return $this->discount;
+    }
+
+    public function setDiscount(?string $discount): self
+    {
+        $this->discount = $discount;
+
+        return $this;
+    }
+
+    public function getRuleType(): int
+    {
+        return $this->ruleType;
+    }
+
+    public function setRuleType(int $ruleType): self
+    {
+        $this->ruleType = $ruleType;
+
+        return $this;
+    }
+
+    public function isOneOf(): bool
+    {
+        return $this->ruleType === Gift::TYPE_ONE;
+    }
+
+    public function isAllOf(): bool
+    {
+        return $this->ruleType === Gift::TYPE_ALL;
+    }
+
+    public function getProductId(): ?int
+    {
+        return $this->productId;
+    }
+
+    public function setProductId(?int $productId): self
+    {
+        $this->productId = $productId;
+
+        return $this;
+    }
+
+    public function getQty(): float
+    {
+        return $this->qty;
+    }
+
+    /**
+     * 追加允许数量(同一条规则命中多次时累加)。
+     */
+    public function addQty(float $qty): self
+    {
+        $this->qty += $qty;
+        $this->allowedQty += $qty;
+
+        return $this;
+    }
+
+    /**
+     * 规则重算前清空允许量。
+     */
+    public function resetQtyAllowed(): self
+    {
+        $this->qty = 0;
+        $this->allowedQty = 0;
+
+        return $this;
+    }
+
+    public function getAllowedQty(): float
+    {
+        return $this->allowedQty;
+    }
+
+    public function setAllowedQty(float $qty): self
+    {
+        $this->allowedQty = $qty;
+
+        return $this;
+    }
+
+    public function getReservedQty(): float
+    {
+        return $this->reservedQty;
+    }
+
+    public function setReservedQty(float $qty): self
+    {
+        $this->reservedQty = $qty;
+
+        return $this;
+    }
+
+    public function addReservedQty(float $qty): self
+    {
+        $this->reservedQty += $qty;
+
+        return $this;
+    }
+
+    public function removeReservedQty(float $qty): self
+    {
+        $this->reservedQty = max(0, $this->reservedQty - $qty);
+
+        return $this;
+    }
+
+    public function getStoredQty(): float
+    {
+        return $this->storedQty;
+    }
+
+    public function setStoredQty(float $qty): self
+    {
+        $this->storedQty = $qty;
+
+        return $this;
+    }
+
+    public function getMinimalPrice(): ?float
+    {
+        return $this->minimalPrice;
+    }
+
+    public function setMinimalPrice(?float $minimalPrice): self
+    {
+        $this->minimalPrice = $minimalPrice;
+
+        return $this;
+    }
+
+    public function getCartItemId(): ?int
+    {
+        return $this->cartItemId;
+    }
+
+    public function setCartItemId(?int $cartItemId): self
+    {
+        $this->cartItemId = $cartItemId;
+
+        return $this;
+    }
+
+    public function isDeleted(): bool
+    {
+        return $this->deleted;
+    }    public function setDeleted(bool $deleted): self
+    {
+        $this->deleted = $deleted;
+
+        return $this;
+    }
+
+    public function isAutoAdd(): bool
+    {
+        return $this->autoAdd;
+    }
+
+    public function setAutoAdd(bool $autoAdd): self
+    {
+        $this->autoAdd = $autoAdd;
+
+        return $this;
+    }
+
+    public function getSortOrder(): int
+    {
+        return $this->sortOrder;
+    }
+
+    public function setSortOrder(int $sortOrder): self
+    {
+        $this->sortOrder = $sortOrder;
+
+        return $this;
+    }
+
+    /**
+     * 还差几个需要补进购物车。
+     */
+    public function getQtyToProcess(): float
+    {
+        return max(0, $this->allowedQty - $this->reservedQty);
+    }
+
+    /**
+     * 由分组裁剪后的实际处理数量。
+     */
+    public function setProcessQty(?float $qty): self
+    {
+        $this->processQty = $qty === null ? null : max(0, $qty);
+
+        return $this;
+    }
+
+    /**
+     * 本轮实际要加购的数量。
+     */
+    public function getProcessQty(): float
+    {
+        if ($this->processQty !== null) {
+            return $this->processQty;
+        }
+
+        return $this->isDeleted() ? 0 : $this->getQtyToProcess();
+    }
+
+    /**
+     * 唯一键。
+     */
+    public function getKey(): string
+    {
+        return $this->ruleId.':'.$this->sku;
+    }
+}

+ 237 - 0
packages/Longyi/FreeGift/src/Services/GiftItemsGroup.php

@@ -0,0 +1,237 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+use Longyi\FreeGift\Config\Gift;
+
+/**
+ * 一个购物车下的全部赠品候选集合。
+ *
+ * 对应 Amasty 的 ItemRegistry\PromoItemsGroup。
+ */
+class GiftItemsGroup
+{
+    /**
+     * @var array<string, GiftItem> key = "{ruleId}:{sku}"
+     */
+    protected array $items = [];
+
+    /**
+     * 「一组合计送 N 个」的规则名额,key = ruleId,value = 本组允许总量。
+     *
+     * @var array<int, float>
+     */
+    protected array $oneOfCaps = [];
+
+    /**
+     * 「一组合计送 N 个」的全部规则 ID。
+     *
+     * @return int[]
+     */
+    public function getOneOfRuleIds(): array
+    {
+        return array_map('intval', array_keys($this->oneOfCaps));
+    }
+
+    /**
+     * 「一组合计送 N 个」规则的整组名额。
+     */
+    public function getCapForOneOf(int $ruleId): float
+    {
+        return (float) ($this->oneOfCaps[$ruleId] ?? 0);
+    }
+
+    /**
+     * 注册一组赠品候选。
+     *
+     * @param  array<string, float>  $map  sku => 本条规则允许赠送的数量
+     * @param  int  $ruleId
+     * @param  string|null  $discount
+     * @param  int  $ruleType
+     * @param  bool  $autoAdd
+     * @param  int  $sortOrder
+     * @param  float|null  $minimalPrice
+     */
+    public function registerMap(
+        array $map,
+        int $ruleId,
+        ?string $discount = null,
+        int $ruleType = Gift::TYPE_ALL,
+        bool $autoAdd = true,
+        int $sortOrder = 0,
+        ?float $minimalPrice = null
+    ): void {
+        if (empty($map)) {
+            return;
+        }
+
+        $maxQty = 0;
+
+        foreach ($map as $sku => $qty) {
+            $qty = (float) $qty;
+
+            if ($qty < 1) {
+                continue;
+            }
+
+            $maxQty = max($maxQty, $qty);
+
+            $item = $this->getItem((string) $sku, $ruleId);
+
+            if ($item) {
+                $item->addQty($qty)
+                    ->setDiscount($discount)
+                    ->setRuleType($ruleType)
+                    ->setAutoAdd($autoAdd)
+                    ->setMinimalPrice($minimalPrice);
+            } else {
+                $item = new GiftItem((string) $sku, $ruleId, $discount, $ruleType, $autoAdd);
+
+                $item->setSortOrder($sortOrder)
+                    ->setMinimalPrice($minimalPrice)
+                    ->addQty($qty);
+
+                $this->items[$item->getKey()] = $item;
+            }
+        }
+
+        // 「一组合计送 N 个」:整组共享 N 个名额,N 由单条规则的允许量决定
+        if ($ruleType === Gift::TYPE_ONE && $maxQty > 0) {
+            $this->oneOfCaps[$ruleId] = ($this->oneOfCaps[$ruleId] ?? 0) + $maxQty;
+        }
+    }
+
+    /**
+     * 直接放入一个候选(回填历史状态时使用,不累加数量)。
+     */
+    public function put(GiftItem $item): void
+    {
+        $this->items[$item->getKey()] = $item;
+    }
+
+    /**
+     * 规则重算前清空全部允许量(保留 id / reservedQty / deleted 等状态)。
+     */
+    public function resetQtyAllowed(): void
+    {
+        foreach ($this->items as $item) {
+            $item->resetQtyAllowed()->setProcessQty(null);
+        }
+
+        $this->oneOfCaps = [];
+    }
+
+    /**
+     * 按 SKU + 规则取候选。
+     */
+    public function getItem(string $sku, int $ruleId): ?GiftItem
+    {
+        return $this->items[$ruleId.':'.$sku] ?? null;
+    }
+
+    /**
+     * 全部候选。
+     *
+     * @return GiftItem[]
+     */
+    public function getItems(): array
+    {
+        return array_values($this->items);
+    }
+
+    /**
+     * 某条规则下的全部候选。
+     *
+     * @return GiftItem[]
+     */
+    public function getItemsByRule(int $ruleId): array
+    {
+        return array_values(array_filter(
+            $this->items,
+            fn (GiftItem $item) => $item->getRuleId() === $ruleId
+        ));
+    }
+
+    /**
+     * 「一组合计送 N 个」规则下,本组还剩多少名额。
+     */
+    public function getRemainingForOneOf(int $ruleId): float
+    {
+        $cap = $this->oneOfCaps[$ruleId] ?? 0;
+
+        if ($cap < 1) {
+            return 0;
+        }
+
+        $used = 0;
+
+        foreach ($this->getItemsByRule($ruleId) as $item) {
+            $used += $item->getReservedQty();
+        }
+
+        return max(0, $cap - $used);
+    }
+
+    /**
+     * 需要自动加购的候选列表。
+     *
+     * 「一组合计送 N 个」的规则由这里统一分配名额,避免整组超送。
+     *
+     * @return GiftItem[]
+     */
+    public function getItemsForAutoAdd(): array
+    {
+        $result = [];
+
+        // 1) 每个 SKU 各送 N 个:互不影响,直接排队
+        foreach ($this->items as $item) {
+            if (
+                $item->isOneOf()
+                || ! $item->isAutoAdd()
+                || $item->isDeleted()
+                || $item->getQtyToProcess() < 1
+            ) {
+                continue;
+            }
+
+            $result[] = $item;
+        }
+
+        // 2) 一组合计送 N 个:按 sortOrder 依次补足,用完名额即止
+        foreach (array_keys($this->oneOfCaps) as $ruleId) {
+            $remaining = $this->getRemainingForOneOf($ruleId);
+
+            if ($remaining < 1) {
+                continue;
+            }
+
+            $groupItems = $this->getItemsByRule($ruleId);
+
+            usort($groupItems, fn (GiftItem $a, GiftItem $b) => $a->getSortOrder() <=> $b->getSortOrder());
+
+            foreach ($groupItems as $item) {
+                if ($remaining < 1) {
+                    break;
+                }
+
+                if (! $item->isAutoAdd() || $item->isDeleted()) {
+                    continue;
+                }
+
+                $need = min($item->getQtyToProcess(), $remaining);
+
+                if ($need < 1) {
+                    continue;
+                }
+
+                $item->setProcessQty($need);
+
+                $remaining -= $need;
+
+                $result[] = $item;
+            }
+        }
+
+        return $result;
+    }
+}

+ 572 - 0
packages/Longyi/FreeGift/src/Services/GiftProcessor.php

@@ -0,0 +1,572 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Longyi\FreeGift\Repositories\FreeGiftRuleRepository;
+use Longyi\FreeGift\Services\Actions\GiftActionPool;
+use Webkul\Checkout\Repositories\CartItemRepository;
+use Webkul\Product\Repositories\ProductRepository;
+use Webkul\Rule\Helpers\Validator;
+
+/**
+ * 赠品主流程编排。
+ *
+ * 对应 Amasty 的 Plugin\Quote\TotalsCollectorPlugin,两阶段:
+ *  阶段 A  registerGiftItems  按规则算出「该送什么、能送几个」
+ *  阶段 B1 syncGiftItems      校正已经在购物车里的赠品行(超额缩、失效删)
+ *  阶段 B2 autoAddGiftItems   把差额补进 cart_items
+ *
+ * 全程由 checkout.cart.collect.totals.before 触发,拿到的购物车对象是
+ * Webkul\Checkout\Contracts\Cart(模型实例)。
+ *
+ * 重要:这里绝不调用 cart()->addProduct(),因为 addProduct() 内部会再次
+ * collectTotals() 导致无限递归;赠品行一律用 CartItemRepository 直写。
+ */
+class GiftProcessor
+{
+    /**
+     * 重入保护(等效于 Amasty 的 amastyFreeGiftProcessed 标记)。
+     */
+    protected bool $running = false;
+
+    public function __construct(
+        protected GiftRegistry $registry,
+        protected GiftRuleResolver $ruleResolver,
+        protected GiftActionPool $actionPool,
+        protected GiftDiscountCalculator $calculator,
+        protected GiftProductValidator $productValidator,
+        protected FreeGiftRuleRepository $giftRuleRepository,
+        protected FreeGiftItemRepository $giftItemRepository,
+        protected CartItemRepository $cartItemRepository,
+        protected ProductRepository $productRepository,
+        protected Validator $validator
+    ) {}
+
+    /**
+     * 入口。
+     *
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    public function process($cart): void
+    {
+        if ($this->running || ! $cart || ! $cart->id) {
+            return;
+        }
+
+        $this->running = true;
+
+        try {
+            $cartId = (int) $cart->id;
+
+            // cart_items 才是真相:调用方(如弹窗加购 GiftController::add)在
+            // 写入赠品行之前可能已经加载过 items / all_items 关联,本请求内
+            // 后续 collectTotals() 若用这份陈旧缓存判断「购物车里有没有赠品」,
+            // 就会把同一个赠品再加一次(重复行)。这里强制重新加载。
+            $cart->unsetRelation('items')->unsetRelation('all_items');
+
+            $group = $this->registry->group($cartId);
+
+            // 1. 清空「可送数量」,交给规则重算
+            $group->resetQtyAllowed();
+
+            // 2. 空车:恢复被用户手动删除的赠品的自动加购资格
+            if (! $cart->all_items->count()) {
+                $this->giftItemRepository->resetDeleted($cartId);
+
+                return;
+            }
+
+            // 3. 以购物车真实数据校准「已占用数量」
+            $this->registry->syncReservedQty($cart, $group);
+
+            // 4. 阶段 A
+            $this->registerGiftItems($cart, $group);
+
+            // 5. 阶段 B1
+            $this->syncGiftItems($cart, $group);
+
+            // 6. 阶段 B2
+            $this->autoAddGiftItems($cart, $group);
+
+            // 7. 清理孤儿赠品行(历史残留的重复行、规则下线后的残留)
+            $this->removeOrphanGiftRows($cart, $group);
+
+            // 8. 落库
+            $this->registry->persist($cartId);
+        } finally {
+            $this->running = false;
+        }
+    }
+
+    /**
+     * 阶段 A:把命中的规则转成赠品候选。
+     *
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    protected function registerGiftItems($cart, GiftItemsGroup $group): void
+    {
+        foreach ($this->ruleResolver->getApplicableRules($cart) as $rule) {
+            $giftRule = $this->giftRuleRepository->findByCartRuleId($rule->id);
+
+            if (! $giftRule) {
+                continue;
+            }
+
+            $action = $this->actionPool->get($rule->action_type);
+
+            if (! $action) {
+                continue;
+            }
+
+            // 收集「命中条件」且「本身不是赠品」的购物车行
+            $matchedItems = [];
+
+            foreach ($cart->all_items as $item) {
+                if ($item->additional[Gift::CART_ITEM_FLAG] ?? null) {
+                    continue;
+                }
+
+                if (! $this->validator->validate($rule, $item)) {
+                    continue;
+                }
+
+                $matchedItems[] = $item;
+            }
+
+            if (empty($matchedItems)) {
+                continue;
+            }
+
+            $map = $action->getGiftQtyMap($rule, $matchedItems, $giftRule, $cart);
+
+            if (empty($map)) {
+                continue;
+            }
+
+            $group->registerMap(
+                $map,
+                (int) $rule->id,
+                $giftRule->items_discount,
+                (int) $giftRule->type,
+                (bool) $giftRule->auto_add,
+                (int) $rule->sort_order,
+                $giftRule->minimal_items_price
+            );
+        }
+    }
+
+    /**
+     * 阶段 B1:校正已经在购物车里的赠品行。
+     *
+     * 只做「减少」和「移除」,不做「增加」——增加统一交给 B2,
+     * 这样才能正确处理「用户手动删过就不再自动加回」。
+     *
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    protected function syncGiftItems($cart, GiftItemsGroup $group): void
+    {
+        foreach ($group->getItems() as $gift) {
+            $cartItem = $this->findGiftCartItem($cart, $gift);
+
+            if (! $cartItem) {
+                $gift->setCartItemId(null)->setReservedQty(0);
+
+                continue;
+            }
+
+            $gift->setCartItemId((int) $cartItem->id);
+
+            // 规则已不再成立 → 把赠品从购物车里摘掉
+            if ($gift->getAllowedQty() < 1) {
+                $this->cartItemRepository->delete($cartItem->id);
+
+                $gift->setCartItemId(null)->setReservedQty(0);
+
+                continue;
+            }
+
+            // 规则允许量变小 → 缩到允许量
+            if ($gift->getAllowedQty() < $gift->getReservedQty()) {
+                $qty = $gift->getAllowedQty();
+
+                $cartItem = $this->cartItemRepository->update([
+                    'quantity'   => $qty,
+                    'total'      => (float) $cartItem->base_price * $qty,
+                    'base_total' => (float) $cartItem->base_price * $qty,
+                    'additional' => array_merge($cartItem->additional ?? [], ['quantity' => $qty]),
+                ], $cartItem->id);
+
+                $gift->setReservedQty($qty);
+            }
+
+            // 校正价格:collectTotals() 开头的 validateItems() 会按商品现价
+            // 重写行价格,赠品的折后价会被冲成原价,必须在同一轮写回
+            $this->applyGiftPrice($cartItem, $gift);
+        }
+
+        $this->shrinkOneOfGroups($cart, $group);
+    }
+
+    /**
+     * 「一组合计送 N 个」整组超发收缩。
+     *
+     * 手动加购等路径曾绕过整组名额(如名额 1 个却躺了两行),这里按
+     * sortOrder 优先级收缩:保住排序靠前的行,裁掉多余的行,实现自愈。
+     *
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    protected function shrinkOneOfGroups($cart, GiftItemsGroup $group): void
+    {
+        foreach ($group->getOneOfRuleIds() as $ruleId) {
+            $cap = $group->getCapForOneOf($ruleId);
+
+            $items = $group->getItemsByRule($ruleId);
+
+            $used = 0;
+
+            foreach ($items as $gift) {
+                $used += $gift->getReservedQty();
+            }
+
+            if ($used <= max(0, $cap)) {
+                continue;
+            }
+
+            // 排序靠后的先缩
+            usort($items, fn (GiftItem $a, GiftItem $b) => $b->getSortOrder() <=> $a->getSortOrder());
+
+            foreach ($items as $gift) {
+                if ($used <= $cap) {
+                    break;
+                }
+
+                $excess = min($gift->getReservedQty(), $used - $cap);
+
+                if ($excess < 1) {
+                    continue;
+                }
+
+                $cartItem = $this->findGiftCartItem($cart, $gift);
+
+                if (! $cartItem) {
+                    $gift->setReservedQty(max(0, $gift->getReservedQty() - $excess));
+                } elseif ((float) $cartItem->quantity - $excess < 1) {
+                    $this->cartItemRepository->delete($cartItem->id);
+
+                    $gift->setCartItemId(null)->setReservedQty(0);
+                } else {
+                    $qty = (float) $cartItem->quantity - $excess;
+
+                    $this->cartItemRepository->update([
+                        'quantity'   => $qty,
+                        'total'      => (float) $cartItem->base_price * $qty,
+                        'base_total' => (float) $cartItem->base_price * $qty,
+                        'additional' => array_merge($cartItem->additional ?? [], ['quantity' => $qty]),
+                    ], $cartItem->id);
+
+                    $gift->setReservedQty($qty);
+                }
+
+                $used -= $excess;
+            }
+        }
+    }
+
+    /**
+     * 阶段 B2:把短缺的赠品补进购物车。
+     *
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    protected function autoAddGiftItems($cart, GiftItemsGroup $group): void
+    {
+        if (! $this->isAutoAddEnabled()) {
+            return;
+        }
+
+        foreach ($group->getItemsForAutoAdd() as $gift) {
+            $qty = $gift->getProcessQty();
+
+            if ($qty < 1) {
+                continue;
+            }
+
+            $product = $this->productRepository->findOneWhere(['sku' => $gift->getSku()]);
+
+            if (! $this->productValidator->isEligible($product, $qty)) {
+                continue;
+            }
+
+            $gift->setProductId((int) $product->id);
+
+            $cartItem = $this->writeGiftCartItem($cart, $product, $gift, $qty, $gift->getCartItemId());
+
+            $gift->setCartItemId((int) $cartItem->id)
+                ->setReservedQty($qty);
+        }
+    }
+
+    /**
+     * 手动把某个赠品加进购物车(弹窗选择 / 手动模式)。
+     *
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     * @param  string  $sku
+     * @param  int  $ruleId
+     * @param  float  $qty
+     * @return array{success: bool, message: string, cart_item_id: int|null}
+     */
+    public function addGiftToCart($cart, string $sku, int $ruleId, float $qty = 1): array
+    {
+        if (! $cart || ! $cart->id) {
+            return ['success' => false, 'message' => trans('free_gift::app.cart.gift-rule-inactive'), 'cart_item_id' => null];
+        }
+
+        $row = $this->giftItemRepository->findOne((int) $cart->id, $sku, $ruleId);
+
+        if (! $row) {
+            return ['success' => false, 'message' => trans('free_gift::app.cart.gift-rule-inactive'), 'cart_item_id' => null];
+        }
+
+        $remaining = (float) $row->allowed_qty - (float) $row->reserved_qty;
+
+        // 「一组合计送 N 个」:名额整组共享,必须按组重算剩余,
+        // 否则每个 SKU 各自"还剩 1 个"时能把整组超发
+        if ((int) $row->rule_type === Gift::TYPE_ONE) {
+            $rows = $this->giftItemRepository->getByCart((int) $cart->id)
+                ->where('rule_id', $ruleId);
+
+            $cap = (float) $rows->max('allowed_qty');
+            $used = (float) $rows->sum('reserved_qty');
+
+            $remaining = max(0, $cap - $used);
+        }
+
+        if ($remaining < 1) {
+            return ['success' => false, 'message' => trans('free_gift::app.cart.gift-unavailable'), 'cart_item_id' => null];
+        }
+
+        $qty = min($qty, $remaining);
+
+        $product = $this->productRepository->findOneWhere(['sku' => $sku]);
+
+        if (! $this->productValidator->isEligible($product, $qty)) {
+            return ['success' => false, 'message' => trans('free_gift::app.cart.gift-unavailable'), 'cart_item_id' => null];
+        }
+
+        $gift = new GiftItem(
+            $sku,
+            $ruleId,
+            $row->discount_amount,
+            (int) $row->rule_type,
+            (bool) $row->auto_add
+        );
+
+        $giftRule = $this->giftRuleRepository->findByCartRuleId($ruleId);
+
+        $gift->setProductId((int) $product->id)
+            ->setMinimalPrice($giftRule?->minimal_items_price);
+
+        $existingId = $this->findGiftCartItem($cart, $gift)?->id ?: $row->cart_item_id;
+
+        $cartItem = $this->writeGiftCartItem($cart, $product, $gift, $qty, $existingId);
+
+        $this->giftItemRepository->update([
+            'cart_item_id'    => $cartItem->id,
+            'reserved_qty'    => $qty,
+            'is_item_deleted' => 0,
+        ], $row->id);
+
+        return [
+            'success'      => true,
+            'message'      => trans('free_gift::app.cart.gift-added'),
+            'cart_item_id' => (int) $cartItem->id,
+        ];
+    }
+
+    /**
+     * 写入/更新一条赠品购物车行。
+     *
+     * 这里绝不走 cart()->addProduct(),否则 addProduct() 内部的 collectTotals()
+     * 会再次触发本监听器,形成无限递归。
+     *
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     * @param  \Webkul\Product\Contracts\Product  $product
+     */
+    protected function writeGiftCartItem($cart, $product, GiftItem $gift, float $qty, $cartItemId = null)
+    {
+        $originalPrice = $this->getOriginalPrice($product, $qty);
+
+        // 赠品实付单价:原价 - 折扣,并以 minimal_items_price 兜底
+        $price = $this->calculator->priceFor($gift->getDiscount(), $originalPrice, $gift->getMinimalPrice());
+
+        $weight = (float) ($product->weight ?? 0);
+
+        $payload = [
+            'quantity'            => $qty,
+            'price'               => $price,
+            'base_price'          => $price,
+            'price_incl_tax'      => $price,
+            'base_price_incl_tax' => $price,
+            'total'               => $price * $qty,
+            'base_total'          => $price * $qty,
+            'total_incl_tax'      => $price * $qty,
+            'base_total_incl_tax' => $price * $qty,
+            'tax_percent'         => 0,
+            'tax_amount'          => 0,
+            'base_tax_amount'     => 0,
+            'discount_percent'    => 0,
+            'discount_amount'     => 0,
+            'base_discount_amount' => 0,
+            'weight'              => $weight,
+            'total_weight'        => $weight * $qty,
+            'base_total_weight'   => $weight * $qty,
+            'additional'          => [
+                Gift::CART_ITEM_FLAG       => $gift->getRuleId(),
+                'free_gift'                => 1,
+                'free_gift_original_price' => $originalPrice,
+                'quantity'                 => $qty,
+            ],
+        ];
+
+        if ($cartItemId) {
+            $updated = $this->cartItemRepository->update($payload, $cartItemId);
+
+            // 指针可能悬空(赠品行被删过 / 购物车被重建),退回新建
+            if ($updated && (int) $updated->cart_id === (int) $cart->id) {
+                return $updated;
+            }
+        }
+
+        return $this->cartItemRepository->create(array_merge($payload, [
+            'cart_id'    => $cart->id,
+            'product_id' => $product->id,
+            'sku'        => $product->sku,
+            'type'       => $product->type,
+            'name'       => $product->name,
+        ]));
+    }
+
+    /**
+     * 在购物车里找到某条赠品候选对应的购物车行。
+     *
+     * 同一个 SKU 被两条规则赠送时会拆成两行,所以必须用
+     * 「规则 ID + SKU」两个条件一起找。
+     *
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    protected function findGiftCartItem($cart, GiftItem $gift)
+    {
+        foreach ($cart->all_items as $item) {
+            if ((int) ($item->additional[Gift::CART_ITEM_FLAG] ?? 0) !== $gift->getRuleId()) {
+                continue;
+            }
+
+            if ($item->sku !== $gift->getSku()) {
+                continue;
+            }
+
+            return $item;
+        }
+
+        return null;
+    }
+
+    /**
+     * 赠品行的折后单价基准(商品现价)。
+     *
+     * 与 Webkul 的 validateCartItem 用同一口径:取含特价、目录促销的最终价,
+     * 而不是 catalog 里的标价,保证「赠品价 = 现价 × 折扣」。
+     */
+    protected function getOriginalPrice($product, float $qty): float
+    {
+        try {
+            $finalPrice = (float) $product->getTypeInstance()->getFinalPrice($qty);
+        } catch (\Throwable) {
+            $finalPrice = 0;
+        }
+
+        return $finalPrice > 0 ? $finalPrice : (float) $product->price;
+    }
+
+    /**
+     * 把赠品行的价格校正回赠品价。
+     *
+     * Webkul\Checkout\Cart::collectTotals() 一进来就跑 validateItems(),里面的
+     * Simple::validateCartItem() 发现「商品现价 != 行价格」就会把行价格改回
+     * 商品现价 —— 赠品的折后价(0 元 / 最低价)每轮都被冲成原价,到结算页露馅。
+     * 本监听器在该重写之后触发,这里按规则把价格写回,购物车和结算页就一致了。
+     */
+    protected function applyGiftPrice($cartItem, GiftItem $gift): void
+    {
+        $product = $this->productRepository->findOneWhere(['sku' => $gift->getSku()]);
+
+        if (! $product) {
+            return;
+        }
+
+        $originalPrice = $this->getOriginalPrice($product, (float) $cartItem->quantity);
+
+        $price = $this->calculator->priceFor($gift->getDiscount(), $originalPrice, $gift->getMinimalPrice());
+
+        if ((float) $cartItem->base_price === (float) $price) {
+            return;
+        }
+
+        $qty = (float) $cartItem->quantity;
+
+        $this->cartItemRepository->update([
+            'price'               => $price,
+            'price_incl_tax'      => $price,
+            'base_price'          => $price,
+            'base_price_incl_tax' => $price,
+            'total'               => $price * $qty,
+            'total_incl_tax'      => $price * $qty,
+            'base_total'          => $price * $qty,
+            'base_total_incl_tax' => $price * $qty,
+        ], $cartItem->id);
+    }
+
+    /**
+     * 清理「孤儿赠品行」。
+     *
+     * cart_items 里带赠品标记的行,必须能对上注册表里「规则 + SKU」的指针行;
+     * 对不上的都是残留(陈旧关联/并发导致的重复加购、规则下线等),直接删掉,
+     * 否则购物车里会出现两行一模一样的赠品。
+     */
+    protected function removeOrphanGiftRows($cart, GiftItemsGroup $group): void
+    {
+        $claimed = [];
+
+        foreach ($group->getItems() as $gift) {
+            if ($gift->getCartItemId()) {
+                $claimed[$gift->getKey()] = (int) $gift->getCartItemId();
+            }
+        }
+
+        foreach ($cart->all_items as $cartItem) {
+            $ruleId = $cartItem->additional[Gift::CART_ITEM_FLAG] ?? null;
+
+            if (! $ruleId) {
+                continue;
+            }
+
+            if (($claimed[((int) $ruleId).':'.$cartItem->sku] ?? null) === (int) $cartItem->id) {
+                continue;
+            }
+
+            $this->cartItemRepository->delete($cartItem->id);
+        }
+    }
+
+    /**
+     * 自动加购总开关。
+     *
+     * 后台配置的是「禁用」开关,所以缺省(配置从未保存)时视为开启,
+     * 避免商家没进过后台设置就把自动加购关掉了。
+     */
+    protected function isAutoAddEnabled(): bool
+    {
+        return ! (bool) core()->getConfigData('sales.free_gift.general.disable_auto_add');
+    }
+}

+ 52 - 0
packages/Longyi/FreeGift/src/Services/GiftProductValidator.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+use Webkul\Product\Contracts\Product as ProductContract;
+
+/**
+ * 赠品资格校验(对应 Amasty 的 Model\Product + InStockFilterApplier)。
+ *
+ * 注意:赠品行是绕过 prepareForCart() 直接写 cart_items 的,
+ * 所以这里只放行 simple / virtual,避免 configurable / bundle / grouped
+ * 在渲染、税费、下单链路里炸掉。
+ */
+class GiftProductValidator
+{
+    /**
+     * 允许作为赠品的商品类型。
+     */
+    public const ALLOWED_TYPES = ['simple', 'virtual'];
+
+    /**
+     * 商品能否作为赠品送出去。
+     */
+    public function isEligible($product, float $qty): bool
+    {
+        if (! $product) {
+            return false;
+        }
+
+        if (! $product->status) {
+            return false;
+        }
+
+        if (! in_array($product->type, self::ALLOWED_TYPES, true)) {
+            return false;
+        }
+
+        return $this->isInStock($product, $qty);
+    }
+
+    /**
+     * 库存是否足够。
+     */
+    public function isInStock(ProductContract $product, float $qty): bool
+    {
+        if (! $product->isStockable()) {
+            return true;
+        }
+
+        return $product->haveSufficientQuantity((int) ceil($qty));
+    }
+}

+ 158 - 0
packages/Longyi/FreeGift/src/Services/GiftRegistry.php

@@ -0,0 +1,158 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Webkul\Checkout\Contracts\Cart as CartContract;
+
+/**
+ * 单次请求内的赠品注册表(按购物车维度)。
+ *
+ * 对应 Amasty 的 Model\Registry,但这里额外负责:
+ *  - 首次访问时从 free_gift_items 回填状态(reservedQty / is_item_deleted)
+ *  - 规则重算结束后把结果写回 free_gift_items
+ */
+class GiftRegistry
+{
+    /**
+     * @var array<int, GiftItemsGroup>
+     */
+    protected array $groups = [];
+
+    /**
+     * @var array<int, bool>
+     */
+    protected array $hydrated = [];
+
+    public function __construct(
+        protected FreeGiftItemRepository $giftItemRepository
+    ) {}
+
+    /**
+     * 取(或初始化)某个购物车的赠品分组。
+     */
+    public function group(int $cartId): GiftItemsGroup
+    {
+        if (! isset($this->groups[$cartId])) {
+            $this->groups[$cartId] = new GiftItemsGroup();
+        }
+
+        if (! isset($this->hydrated[$cartId])) {
+            $this->hydrate($cartId);
+
+            $this->hydrated[$cartId] = true;
+        }
+
+        return $this->groups[$cartId];
+    }
+
+    /**
+     * 用购物车里的真实赠品行刷新 reservedQty。
+     *
+     * free_gift_items 只是缓存,cart_items 才是真相;每次重算都以购物车为准,
+     * 避免用户在另一个标签页删了东西导致数量对不上。
+     */
+    public function syncReservedQty(CartContract $cart, GiftItemsGroup $group): void
+    {
+        $reserved = [];
+
+        foreach ($cart->all_items as $cartItem) {
+            $ruleId = $cartItem->additional[Gift::CART_ITEM_FLAG] ?? null;
+
+            if (! $ruleId) {
+                continue;
+            }
+
+            $key = ((int) $ruleId).':'.$cartItem->sku;
+
+            $reserved[$key] = ($reserved[$key] ?? 0) + (float) $cartItem->quantity;
+        }
+
+        foreach ($group->getItems() as $item) {
+            $item->setReservedQty($reserved[$item->getKey()] ?? 0);
+
+            unset($reserved[$item->getKey()]);
+        }
+
+        // 购物车里存在但注册表里没有的赠品行(规则被删掉等)交给 syncGiftItems 清理
+    }
+
+    /**
+     * 把注册表写回 free_gift_items。
+     */
+    public function persist(int $cartId): void
+    {
+        if (! isset($this->groups[$cartId])) {
+            return;
+        }
+
+        foreach ($this->groups[$cartId]->getItems() as $item) {
+            // 规则已经不再命中,且购物车里也没有残留 → 直接删掉状态行
+            if ($item->getAllowedQty() < 1) {
+                if ($item->getId()) {
+                    $this->giftItemRepository->delete($item->getId());
+
+                    $item->setId(null);
+                }
+
+                continue;
+            }
+
+            $data = [
+                'cart_id'         => $cartId,
+                'cart_item_id'    => $item->getCartItemId(),
+                'product_id'      => $item->getProductId(),
+                'sku'             => $item->getSku(),
+                'rule_id'         => $item->getRuleId(),
+                'rule_type'       => $item->getRuleType(),
+                'allowed_qty'     => $item->getAllowedQty(),
+                'reserved_qty'    => $item->getReservedQty(),
+                'discount_amount' => $item->getDiscount(),
+                'auto_add'        => $item->isAutoAdd(),
+                'is_item_deleted' => $item->isDeleted(),
+            ];
+
+            if ($item->getId()) {
+                $this->giftItemRepository->update($data, $item->getId());
+            } else {
+                $row = $this->giftItemRepository->create($data);
+
+                $item->setId((int) $row->id);
+            }
+        }
+    }
+
+    /**
+     * 丢弃某个购物车的注册表缓存(购物车被清空 / 合并时使用)。
+     */
+    public function forget(int $cartId): void
+    {
+        unset($this->groups[$cartId], $this->hydrated[$cartId]);
+    }
+
+    /**
+     * 从 free_gift_items 回填。
+     */
+    protected function hydrate(int $cartId): void
+    {
+        foreach ($this->giftItemRepository->getByCart($cartId) as $row) {
+            $item = new GiftItem(
+                (string) $row->sku,
+                (int) $row->rule_id,
+                $row->discount_amount,
+                (int) $row->rule_type,
+                (bool) $row->auto_add
+            );
+
+            $item->setId((int) $row->id)
+                ->setProductId($row->product_id ? (int) $row->product_id : null)
+                ->setCartItemId($row->cart_item_id ? (int) $row->cart_item_id : null)
+                ->setReservedQty((float) $row->reserved_qty)
+                ->setStoredQty((float) $row->allowed_qty)
+                ->setDeleted((bool) $row->is_item_deleted);
+
+            $this->groups[$cartId]->put($item);
+        }
+    }
+}

+ 168 - 0
packages/Longyi/FreeGift/src/Services/GiftRuleResolver.php

@@ -0,0 +1,168 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+use Carbon\Carbon;
+use Longyi\FreeGift\Config\Gift;
+use Webkul\CartRule\Repositories\CartRuleCouponRepository;
+use Webkul\CartRule\Repositories\CartRuleCouponUsageRepository;
+use Webkul\CartRule\Repositories\CartRuleCustomerRepository;
+use Webkul\CartRule\Repositories\CartRuleRepository;
+use Webkul\Customer\Repositories\CustomerRepository;
+
+/**
+ * 找出当前购物车命中的「赠品型」营销规则。
+ *
+ * 刻意不复用 Webkul\CartRule\Helpers\CartRule:
+ * 那个类的 $this->cart 只在 collect() 内部被赋值,构造顺序/实例共享一旦变化,
+ * canProcessRule() 就会在 null 上取值。这里把筛选逻辑独立出来,行为与原生保持一致。
+ */
+class GiftRuleResolver
+{
+    /**
+     * 单次请求内缓存候选规则,collectTotals() 一次请求里会被调用多轮。
+     *
+     * @var \Illuminate\Support\Collection|null
+     */
+    protected $candidates = null;
+
+    public function __construct(
+        protected CustomerRepository $customerRepository,
+        protected CartRuleRepository $cartRuleRepository,
+        protected CartRuleCouponRepository $cartRuleCouponRepository,
+        protected CartRuleCustomerRepository $cartRuleCustomerRepository,
+        protected CartRuleCouponUsageRepository $cartRuleCouponUsageRepository
+    ) {}
+
+    /**
+     * 命中当前购物车的赠品规则(已按 sort_order 升序)。
+     *
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     * @return \Webkul\CartRule\Contracts\CartRule[]
+     */
+    public function getApplicableRules($cart): array
+    {
+        $rules = [];
+
+        foreach ($this->getCandidateRules() as $rule) {
+            if (! $this->passesCoupon($rule, $cart)) {
+                continue;
+            }
+
+            if (! $this->passesUsageLimit($rule, $cart)) {
+                continue;
+            }
+
+            $rules[] = $rule;
+        }
+
+        return $rules;
+    }
+
+    /**
+     * 按客户组 / 渠道 / 时间 / 状态过滤出的候选规则。
+     *
+     * 查询条件与 Webkul\CartRule\Helpers\CartRule::getCartRuleQuery() 对齐。
+     *
+     * @return \Illuminate\Support\Collection
+     */
+    public function getCandidateRules()
+    {
+        if ($this->candidates !== null) {
+            return $this->candidates;
+        }
+
+        return $this->candidates = $this->cartRuleRepository
+            ->leftJoin('cart_rule_customer_groups', 'cart_rules.id', '=', 'cart_rule_customer_groups.cart_rule_id')
+            ->leftJoin('cart_rule_channels', 'cart_rules.id', '=', 'cart_rule_channels.cart_rule_id')
+            ->whereIn('cart_rules.action_type', Gift::ACTION_TYPES)
+            ->where('cart_rule_customer_groups.customer_group_id', $this->customerRepository->getCurrentGroup()->id)
+            ->where('cart_rule_channels.channel_id', core()->getCurrentChannel()->id)
+            ->where(function ($query) {
+                $query->where('cart_rules.starts_from', '<=', Carbon::now()->format('Y-m-d H:m:s'))
+                    ->orWhereNull('cart_rules.starts_from');
+            })
+            ->where(function ($query) {
+                $query->where('cart_rules.ends_till', '>=', Carbon::now()->format('Y-m-d H:m:s'))
+                    ->orWhereNull('cart_rules.ends_till');
+            })
+            ->where('cart_rules.status', 1)
+            ->orderBy('cart_rules.sort_order', 'asc')
+            ->get();
+    }
+
+    /**
+     * 优惠券校验(对齐原生 canProcessRule 的 coupon 分支)。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    protected function passesCoupon($rule, $cart): bool
+    {
+        if (! $rule->coupon_type) {
+            return true;
+        }
+
+        if (! $cart->coupon_code) {
+            return false;
+        }
+
+        $coupon = $this->cartRuleCouponRepository->findOneWhere([
+            'cart_rule_id' => $rule->id,
+            'code'         => $cart->coupon_code,
+        ]);
+
+        if (! $coupon || $coupon->code !== $cart->coupon_code) {
+            return false;
+        }
+
+        if ($coupon->usage_limit && $coupon->times_used >= $coupon->usage_limit) {
+            return false;
+        }
+
+        // 限定了客户的优惠券(customer_id 由 2025_06_26 的迁移加入)
+        if ($coupon->customer_id) {
+            if (! $cart->customer_id || $cart->customer_id != $coupon->customer_id) {
+                return false;
+            }
+        }
+
+        // 每个客户的使用次数上限
+        if ($cart->customer_id && $coupon->usage_per_customer) {
+            $couponUsage = $this->cartRuleCouponUsageRepository->findOneWhere([
+                'cart_rule_coupon_id' => $coupon->id,
+                'customer_id'         => $cart->customer_id,
+            ]);
+
+            if ($couponUsage && $couponUsage->times_used >= $coupon->usage_per_customer) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * 每个客户的使用次数上限(规则级)。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    protected function passesUsageLimit($rule, $cart): bool
+    {
+        if (! $rule->usage_per_customer) {
+            return true;
+        }
+
+        $ruleCustomer = $this->cartRuleCustomerRepository->findOneWhere([
+            'cart_rule_id' => $rule->id,
+            'customer_id'  => $cart->customer_id,
+        ]);
+
+        if ($ruleCustomer && $ruleCustomer->times_used >= $rule->usage_per_customer) {
+            return false;
+        }
+
+        return true;
+    }
+}

+ 15 - 1
packages/Webkul/Shop/src/Http/Resources/CartItemResource.php

@@ -34,10 +34,24 @@ class CartItemResource extends JsonResource
             'base_image'                => $this->getTypeInstance()->getBaseImage($this),
             'product_url_key'           => $this->product->url_key,
             'options'                   => $this->formatAdditionalAttributes(),
-            'can_change_qty'            => $this->product ? $this->product->getTypeInstance()->showQuantityBox() : false,
+
+            /**
+             * 赠品行不允许改数量(数量由赠品规则决定),前端因此隐藏数量选择器。
+             */
+            'is_free_gift'              => $this->isFreeGift(),
+            'can_change_qty'            => ($this->product ? $this->product->getTypeInstance()->showQuantityBox() : false)
+                                           && ! $this->isFreeGift(),
         ];
     }
 
+    /**
+     * 是否是赠品行(Longyi\FreeGift 在 additional 里打的标)。
+     */
+    protected function isFreeGift(): bool
+    {
+        return ! empty($this->resource->additional['free_gift_rule_id'] ?? null);
+    }
+
     /**
      * Format the additional attributes.
      */