Explorar o código

Merge branch 'dev-rewardPoints' into dev

# Conflicts:
#	bootstrap/cache/services.php
#	packages/Webkul/BagistoApi/src/Dto/SaveCheckoutCartInput.php
#	packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php
#	packages/Webkul/BagistoApi/src/State/SaveCheckoutCartProcessor.php
bianjunhui hai 1 semana
pai
achega
a166f5d98d

+ 14 - 1
packages/Longyi/RewardPoints/src/Config/TransactionType.php

@@ -85,6 +85,11 @@ class TransactionType
      */
     const FOLLOW = 14;
 
+    /**
+     * 积分抵扣(结账时使用积分抵扣支付金额)
+     */
+    const POINTS_REDEEM = 15;
+
     /**
      * 获取所有类型配置
      *
@@ -198,6 +203,13 @@ class TransactionType
                 'icon' => 'icon-heart',
                 'color' => 'red',
             ],
+            self::POINTS_REDEEM => [
+                'code' => 'points_redeem',
+                'name' => '积分抵扣',
+                'description' => '结账时使用积分抵扣支付金额',
+                'icon' => 'icon-shopping-cart',
+                'color' => 'orange',
+            ],
         ];
     }
 
@@ -210,12 +222,13 @@ class TransactionType
     {
         $all = self::all();
 
-        // 排除特殊类型:管理员操作(99)、过期(10)、兑换礼品卡(11)、取消订单退回(12)
+        // 排除特殊类型:管理员操作(99)、过期(10)、兑换礼品卡(11)、取消订单退回(12)、积分抵扣(15)
         $excludedTypes = [
             self::ADMIN_ACTION,
             self::EXPIRED,
             self::GIFT_CARD_REDEEM,
             self::ORDER_CANCEL_REFUND,
+            self::POINTS_REDEEM,
         ];
 
         foreach ($excludedTypes as $typeId) {

+ 38 - 0
packages/Longyi/RewardPoints/src/Database/Migrations/2026_08_18_000001_add_reward_points_fields_to_cart_table.php

@@ -0,0 +1,38 @@
+<?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
+    {
+        if (! Schema::hasColumn('cart', 'reward_points_used')) {
+            Schema::table('cart', function (Blueprint $table) {
+                $table->integer('reward_points_used')->default(0)->after('base_vip_discount_amount')->comment('结账使用的积分数');
+                $table->decimal('reward_points_amount', 12, 4)->default(0)->after('reward_points_used')->comment('积分抵扣金额');
+                $table->decimal('base_reward_points_amount', 12, 4)->default(0)->after('reward_points_amount')->comment('基础币种积分抵扣金额');
+            });
+        }
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        if (Schema::hasColumn('cart', 'reward_points_used')) {
+            Schema::table('cart', function (Blueprint $table) {
+                $table->dropColumn([
+                    'reward_points_used',
+                    'reward_points_amount',
+                    'base_reward_points_amount',
+                ]);
+            });
+        }
+    }
+};

+ 35 - 0
packages/Longyi/RewardPoints/src/Listeners/OrderEvents.php

@@ -5,6 +5,7 @@ namespace Longyi\RewardPoints\Listeners;
 use Longyi\RewardPoints\Repositories\RewardPointRepository;
 use Longyi\RewardPoints\Models\RewardActiveRule;
 use Longyi\RewardPoints\Models\RewardPointHistory;
+use Longyi\RewardPoints\Config\TransactionType;
 use Longyi\RewardPoints\Services\GrowthValueService;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
@@ -32,6 +33,9 @@ class OrderEvents
             return;
         }
 
+        // 1. 处理积分抵扣:如果结账时使用了积分抵扣,下单时真正扣除积分
+        $this->deductRedeemedPoints($order);
+
         $rule = $this->getOrderRule();
         if (!$rule) {
             return;
@@ -78,6 +82,37 @@ class OrderEvents
             ]);
         }
     }
+    /**
+     * Deduct the reward points that were redeemed against this order.
+     *
+     * The cart stores how many points the customer chose to redeem in the
+     * "reward_points_used" column (set by the GraphQL SaveCheckoutCart flow).
+     */
+    protected function deductRedeemedPoints(Order $order): void
+    {
+        $cart = $order->cart;
+
+        if (! $cart || ! ($pointsUsed = (int) ($cart->reward_points_used ?? 0))) {
+            return;
+        }
+
+        $discountAmount = (float) ($cart->base_reward_points_amount ?? 0);
+
+        $this->rewardPointRepository->deductPoints(
+            $order->customer_id,
+            $pointsUsed,
+            TransactionType::POINTS_REDEEM,
+            'Points redeemed for order #' . $order->increment_id . ' (Discount: ' . core()->formatPrice($discountAmount) . ')',
+            $order->id
+        );
+
+        // 清除购物车积分抵扣标记,避免重复扣减
+        $cart->reward_points_used = 0;
+        $cart->reward_points_amount = 0;
+        $cart->base_reward_points_amount = 0;
+        $cart->save();
+    }
+
     public function handleOrderCancellation(Order $order): void
     {
         if (!$order->customer_id) {

+ 49 - 0
packages/Longyi/RewardPoints/src/Listeners/RewardPointsHandler.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace Longyi\RewardPoints\Listeners;
+
+use Webkul\Checkout\Models\Cart;
+
+class RewardPointsHandler
+{
+    /**
+     * Apply reward points discount to the cart grand total.
+     *
+     * Runs after "checkout.cart.collect.totals.after" (same pattern as gift
+     * cards). The cart's reward_points_used field is set by the GraphQL
+     * SaveCheckoutCartProcessor; here we compute the discount amount and deduct
+     * it from the grand total so the remaining balance is what the customer
+     * actually pays.
+     */
+    public function applyRewardPoints(Cart $cart): void
+    {
+        $pointsUsed = (int) ($cart->reward_points_used ?? 0);
+
+        if ($pointsUsed <= 0) {
+            return;
+        }
+
+        $pointValue = (float) config('rewardpoints.general.point_value', 0.01);
+        $maxDiscountPercentage = (float) config('rewardpoints.general.max_discount_percentage', 100);
+
+        // Discount amount in the cart's current currency.
+        $discountAmount = round($pointsUsed * $pointValue, 2);
+
+        // Cap the discount at the configured percentage of the grand total.
+        $maxDiscountAmount = round((float) $cart->base_grand_total * $maxDiscountPercentage / 100, 2);
+        $baseDiscountAmount = core()->convertToBasePrice($discountAmount);
+
+        if ($baseDiscountAmount > $maxDiscountAmount) {
+            $baseDiscountAmount = $maxDiscountAmount;
+            $discountAmount = core()->convertPrice($baseDiscountAmount);
+        }
+
+        $cart->reward_points_amount = $discountAmount;
+        $cart->base_reward_points_amount = $baseDiscountAmount;
+
+        $cart->grand_total = max(0, round((float) $cart->grand_total - $discountAmount, 2));
+        $cart->base_grand_total = max(0, round((float) $cart->base_grand_total - $baseDiscountAmount, 2));
+
+        $cart->save();
+    }
+}

+ 7 - 0
packages/Longyi/RewardPoints/src/Providers/RewardPointsServiceProvider.php

@@ -3,6 +3,7 @@
 namespace Longyi\RewardPoints\Providers;
 
 use Illuminate\Support\ServiceProvider;
+use Illuminate\Support\Facades\Event;
 use Illuminate\Routing\Router;
 use Longyi\RewardPoints\Providers\EventServiceProvider;
 use Longyi\RewardPoints\Services\CartRewardPoints;
@@ -32,6 +33,12 @@ class RewardPointsServiceProvider extends ServiceProvider
         $this->app->booted(function () {
             $this->loadDatabaseSettings();
         });
+
+        // 结账积分抵扣:在 cart totals 计算后扣除积分抵扣金额
+        Event::listen(
+            'checkout.cart.collect.totals.after',
+            \Longyi\RewardPoints\Listeners\RewardPointsHandler::class.'@applyRewardPoints'
+        );
     }
 
     public function register()

+ 22 - 0
packages/Webkul/BagistoApi/src/Dto/CartData.php

@@ -211,6 +211,22 @@ class CartData
     #[ApiProperty(description: 'Reward points that can be earned from the current cart')]
     public int $earnableRewardPoints = 0;
 
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Reward points redeemed against the current cart')]
+    public int $rewardPointsUsed = 0;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Reward points discount amount')]
+    public ?float $rewardPointsAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Base reward points discount amount')]
+    public ?float $baseRewardPointsAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted reward points discount amount')]
+    public ?string $formattedRewardPointsAmount = null;
+
     #[Groups(['query', 'mutation'])]
     #[ApiProperty(description: 'Subtotal including tax')]
     public ?float $subTotalInclTax = null;
@@ -456,6 +472,12 @@ class CartData
         $data->rewardPoints = 0;
         $data->earnableRewardPoints = 0;
 
+        // 积分抵扣信息
+        $data->rewardPointsUsed = (int) ($cart->reward_points_used ?? 0);
+        $data->rewardPointsAmount = (float) core()->convertPrice($cart->base_reward_points_amount ?? 0);
+        $data->baseRewardPointsAmount = (float) ($cart->base_reward_points_amount ?? 0);
+        $data->formattedRewardPointsAmount = core()->currency($cart->base_reward_points_amount ?? 0);
+
         if ($cart->customer_id) {
             try {
                 $rewardPointRepository = app(\Longyi\RewardPoints\Repositories\RewardPointRepository::class);

+ 4 - 0
packages/Webkul/BagistoApi/src/Dto/SaveCheckoutCartInput.php

@@ -45,4 +45,8 @@ class SaveCheckoutCartInput
     #[ApiProperty(description: 'Shipping insurance toggle. Pass "-1" to leave unchanged, "1" to apply, empty string to remove.')]
     #[SerializedName('useShippingInsurance')]
     public ?string $useShippingInsurance = null;
+    #[ApiProperty(description: 'Reward points to redeem against the order total. Pass "-1" to leave unchanged, "0" to remove.')]
+    #[SerializedName('rewardPoints')]
+    public ?int $rewardPoints = null;
+
 }

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

@@ -263,6 +263,9 @@ class BagistoApiServiceProvider extends ServiceProvider
                 $app->make(GiftCardService::class),
                 $app->make(MemberDiscountService::class),
                 $app->make(ShippingInsuranceService::class)
+                $app->make(\Longyi\Gift\Services\GiftCardService::class),
+                $app->make(MemberDiscountService::class),
+                $app->make(\Longyi\RewardPoints\Repositories\RewardPointRepository::class)
             );
         });
 

+ 49 - 0
packages/Webkul/BagistoApi/src/State/SaveCheckoutCartProcessor.php

@@ -19,6 +19,7 @@ use Webkul\Shipping\Facades\Shipping;
 use Longyi\Gift\Services\GiftCardService;
 use Longyi\Member\Services\MemberDiscountService;
 use Longyi\ShippingInsurance\Services\ShippingInsuranceService;
+use Longyi\RewardPoints\Repositories\RewardPointRepository;
 
 /**
  * GraphQL processor for the SaveCheckoutCart mutation.
@@ -39,6 +40,7 @@ class SaveCheckoutCartProcessor implements ProcessorInterface
         protected GiftCardService $giftCardService,
         protected MemberDiscountService $memberDiscountService,
         protected ShippingInsuranceService $shippingInsuranceService,
+        protected RewardPointRepository $rewardPointRepository,
     ) {}
 
     /**
@@ -88,6 +90,9 @@ class SaveCheckoutCartProcessor implements ProcessorInterface
 
             if ($this->shouldUpdate($data->useShippingInsurance)) {
                 $this->applyShippingInsurance((string) $data->useShippingInsurance);
+
+            if ($data->rewardPoints !== null && $data->rewardPoints !== -1) {
+                $this->applyRewardPoints((int) $data->rewardPoints);
             }
 
             Cart::collectTotals();
@@ -256,5 +261,49 @@ class SaveCheckoutCartProcessor implements ProcessorInterface
         }
 
         $this->shippingInsuranceService->activate();
+
+     * Apply or remove reward points redemption. "0" removes the redemption.
+     *
+     * The actual discount amount is computed and deducted from the grand total
+     * by the RewardPoints listener on the "checkout.cart.collect.totals.after"
+     * event (same pattern as gift cards), so here we only validate and persist
+     * how many points the customer wants to redeem.
+     */
+    private function applyRewardPoints(int $points): void
+    {
+        $cart = Cart::getCart();
+
+        if (! $cart) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-cart'));
+        }
+
+        $customerId = $cart->customer_id;
+
+        if (! $customerId) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.customer-not-found'));
+        }
+
+        // Remove redemption.
+        if ($points <= 0) {
+            $cart->reward_points_used = 0;
+            $cart->reward_points_amount = 0;
+            $cart->base_reward_points_amount = 0;
+            $cart->save();
+
+            Cart::collectTotals();
+
+            return;
+        }
+
+        $availablePoints = (int) $this->rewardPointRepository->getCustomerPoints($customerId);
+
+        if ($points > $availablePoints) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.insufficient-reward-points'));
+        }
+
+        $cart->reward_points_used = $points;
+        $cart->save();
+
+        Cart::collectTotals();
     }
 }