Преглед изворни кода

添加礼品卡过期的定时任务

llp пре 6 дана
родитељ
комит
aa24597e41

+ 62 - 0
packages/Longyi/Gift/src/Console/Commands/ExpireGiftCards.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace Longyi\Gift\Console\Commands;
+
+use Illuminate\Console\Command;
+use Longyi\Gift\Services\GiftCardExpiryService;
+
+class ExpireGiftCards extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'gift-cards:expire
+                            {--dry-run : 只统计已过期的未使用礼品卡数量,不写库}
+                            {--batch=500 : 每批处理条数}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '把已过期的未使用礼品卡状态改为「已过期」';
+
+    public function __construct(protected GiftCardExpiryService $expiryService)
+    {
+        parent::__construct();
+    }
+
+    public function handle(): int
+    {
+        $startedAt = now();
+        $dryRun = (bool) $this->option('dry-run');
+
+        $this->info($dryRun
+            ? '统计已过期的未使用礼品卡...'
+            : '开始处理过期礼品卡...');
+
+        try {
+            $count = $this->expiryService->expire(
+                null,
+                $dryRun,
+                (int) $this->option('batch')
+            );
+        } catch (\Throwable $e) {
+            $this->error('处理失败:'.$e->getMessage());
+            report($e);
+
+            return self::FAILURE;
+        }
+
+        $this->info(sprintf(
+            '%s %d 张礼品卡,耗时 %d 秒。',
+            $dryRun ? '共发现' : '共更新',
+            $count,
+            $startedAt->diffInSeconds(now())
+        ));
+
+        return self::SUCCESS;
+    }
+}

+ 56 - 1
packages/Longyi/Gift/src/Listeners/GiftHandler.php

@@ -3,6 +3,7 @@
 namespace Longyi\Gift\Listeners;
 
 use Webkul\Paypal\Payment\SmartButton;
+use Illuminate\Support\Facades\Log;
 use Webkul\Sales\Repositories\OrderTransactionRepository;
 use Longyi\Gift\Models\GiftCards;
 
@@ -31,7 +32,9 @@ class GiftHandler
         }
         // 获取礼品卡对象
         $giftCard = GiftCards::where('giftcard_number', $cart->giftcard_number)->first();
-        if (!$giftCard) {
+        // 卡不存在 / 已过期 / 没余额 / 不属于当前客户:摘掉购物车上的卡,不做抵扣
+        if (!$giftCard || !$this->isUsable($cart, $giftCard)) {
+            $this->detachGiftCard($cart);
             return;
         }
         $remainingAmountInCurrentCurrency = core()->convertPrice($giftCard->remaining_giftcard_amount);
@@ -47,4 +50,56 @@ class GiftHandler
         $cart->base_grand_total = max(0, round($cart->base_grand_total - $giftCardAmountInBaseCurrency, 2));
         $cart->save();
     }
+
+    /**
+     * 礼品卡当前是否可以抵扣:未过期、还有余额、属于购物车对应的客户。
+     *
+     * @param  \Webkul\Checkout\Models\Cart  $cart
+     * @param  \Longyi\Gift\Models\GiftCards  $giftCard
+     * @return bool
+     */
+    protected function isUsable($cart, $giftCard): bool
+    {
+        if ($giftCard->isExpired()) {
+            return false;
+        }
+
+        if ((float) $giftCard->remaining_giftcard_amount <= 0) {
+            return false;
+        }
+
+        // 两边都有客户编号且对不上,才判定为不是本人的卡(购物车没绑客户时不拦)
+        $cartCustomerId = (int) ($cart->customer_id ?? 0);
+        $cardCustomerId = (int) ($giftCard->customer_id ?? 0);
+
+        if ($cartCustomerId && $cardCustomerId && $cartCustomerId !== $cardCustomerId) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * 把不可用的礼品卡从购物车上摘掉。
+     *
+     * 注意:此时 `grand_total` 是 collectTotals() 刚从商品/运费重算出来的全价,
+     * 里面并没有减过这张卡,所以这里只清空礼品卡字段,**不能再回补金额**。
+     *
+     * @param  \Webkul\Checkout\Models\Cart  $cart
+     * @return void
+     */
+    protected function detachGiftCard($cart): void
+    {
+        Log::info('Gift card detached from cart: '.$cart->giftcard_number, [
+            'cart_id'         => $cart->id,
+            'giftcard_number' => $cart->giftcard_number,
+            'customer_id'     => $cart->customer_id,
+        ]);
+
+        $cart->giftcard_number = null;
+        $cart->giftcard_amount = null;
+        $cart->base_giftcard_amount = null;
+
+        $cart->save();
+    }
 }

+ 49 - 8
packages/Longyi/Gift/src/Models/GiftCards.php

@@ -17,6 +17,29 @@ class GiftCards extends Model implements GiftCardsContract
 {
     use HasFactory;
 
+    /**
+     * 未使用(可用)。
+     */
+    public const STATUS_UNUSED = 1;
+
+    /**
+     * 已使用(余额已用完)。
+     */
+    public const STATUS_USED = 2;
+
+    /**
+     * 已过期(超过 expirationdate,由 gift-cards:expire 定时任务写入)。
+     */
+    public const STATUS_EXPIRED = 3;
+
+    /**
+     * 「已经领过」的状态:未使用 + 已过期(已用完的不算)。
+     *
+     * 礼品卡过期后状态会从「未使用」变成「已过期」,但业务上依然算领过,
+     * 不能再拿积分重复兑换同一渠道的礼品卡 —— 所以前台列表和兑换接口统一用这个范围。
+     */
+    public const CLAIMED_STATUSES = [self::STATUS_UNUSED, self::STATUS_EXPIRED];
+
     protected $table = 'gift_cards';
 
     protected $fillable = [
@@ -104,13 +127,15 @@ class GiftCards extends Model implements GiftCardsContract
         if (empty($giftcardAmount)) {
             throw new \Exception('giftcardAmount is empty');
         }
-        $data = GiftCards::where('customer_id', $customerId)
-            ->where('channel', $channel)
-            ->count();
-        if ($num != 0) {
-            $giftNum =count($data);
-            if($giftNum <= $num){
-                throw new \Exception('gift num is not enough');
+        // $num > 0 表示该渠道最多允许「已领过」(未使用 + 已过期)这么多张,超出就报错
+        if ($num > 0) {
+            $claimedNum = GiftCards::where('customer_id', $customerId)
+                ->where('channel', $channel)
+                ->whereIn('giftcard_status', self::CLAIMED_STATUSES)
+                ->count();
+
+            if ($claimedNum >= $num) {
+                throw new \Exception("Gift card limit reached for channel {$channel} (max: {$num}).");
             }
         }
         $expirationDate = self::calculateExpirationDate($expirationdate);
@@ -121,7 +146,7 @@ class GiftCards extends Model implements GiftCardsContract
             'remaining_giftcard_amount' => $giftcardAmount,
             'customer_id' => $customerId,
             'expirationdate' => $expirationDate,
-            'giftcard_status' => 1,
+            'giftcard_status' => self::STATUS_UNUSED,
             'channel' => $channel,
             'notes' => $notes
         ]);
@@ -156,6 +181,22 @@ class GiftCards extends Model implements GiftCardsContract
 
         throw new \Exception('Invalid expiration date type. Must be integer (days), string (Y-m-d), or Carbon instance.');
     }
+
+    /**
+     * 这张礼品卡是否已过期。
+     *
+     * 状态已被定时任务标成「已过期」,或者过期时间已经过去,都算过期;
+     * expirationdate 为空的卡视为永久有效。
+     */
+    public function isExpired(): bool
+    {
+        if ((int) $this->giftcard_status === self::STATUS_EXPIRED) {
+            return true;
+        }
+
+        return $this->expirationdate !== null && $this->expirationdate->isPast();
+    }
+
     public static function generateGiftCardNumber()
     {
         do {

+ 16 - 0
packages/Longyi/Gift/src/Providers/GiftServiceProvider.php

@@ -5,8 +5,10 @@ namespace Longyi\Gift\Providers;
 use Longyi\Gift\Repositories\CustomInvoiceRepository;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\Facades\Event;
+use Illuminate\Console\Scheduling\Schedule;
 use Longyi\Gift\Providers\EventServiceProvider;
 use Longyi\Gift\Repositories\GiftCardsRepository;
+use Longyi\Gift\Console\Commands\ExpireGiftCards;
 use Webkul\Sales\Repositories\InvoiceRepository;
 use Webkul\Sales\Repositories\OrderRepository;
 use Longyi\Gift\Repositories\CustomOrderRepository;
@@ -21,6 +23,12 @@ class GiftServiceProvider extends ServiceProvider
         $this->app->bind(InvoiceRepository::class, CustomInvoiceRepository::class);
         $this->app->bind(OrderRepository::class, CustomOrderRepository::class);
         $this->registerConfig();
+
+        if ($this->app->runningInConsole()) {
+            $this->commands([
+                ExpireGiftCards::class,
+            ]);
+        }
     }
 
     /**
@@ -43,6 +51,14 @@ class GiftServiceProvider extends ServiceProvider
         Event::listen('bagisto.admin.layout.head', function($viewRenderEventManager) {
             $viewRenderEventManager->addTemplate('gift::admin.layouts.style');
         });
+
+        // 每小时把已过期的未使用礼品卡标记为「已过期」
+        $this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
+            $schedule->command('gift-cards:expire')
+                ->hourly()
+                ->withoutOverlapping();
+        });
+
         $this->app->register(EventServiceProvider::class);
     }
 

+ 4 - 0
packages/Longyi/Gift/src/Resources/lang/en/app.php

@@ -17,6 +17,9 @@ return [
             'customer-id'       => 'Customer ID',
             'expiration-date'   => 'Expiration Date',
             'status'            => 'Status',
+            'unused'            => 'Unused',
+            'used'              => 'Used',
+            'expired'           => 'Expired',
             'notes'             => 'Notes',
             'created-at'        => 'Created At',
             'edit'              => 'Edit',
@@ -61,6 +64,7 @@ return [
             'giftcard-status'            => 'Status',
             'unused'                     => 'Unused',
             'used'                       => 'Used',
+            'expired'                    => 'Expired',
             'validating'                 => 'Validating...',
             'validation-failed'          => 'Validation failed, please try again',
             'create-success'             => 'Gift card created successfully',

+ 4 - 0
packages/Longyi/Gift/src/Resources/lang/zh_CN/app.php

@@ -17,6 +17,9 @@ return [
             'customer-id'       => '客户 ID',
             'expiration-date'   => '过期日期',
             'status'            => '状态',
+            'unused'            => '未使用',
+            'used'              => '已使用',
+            'expired'           => '已过期',
             'notes'             => '备注',
             'created-at'        => '创建时间',
             'edit'              => '编辑',
@@ -61,6 +64,7 @@ return [
             'giftcard-status'            => '状态',
             'unused'                     => '未使用',
             'used'                       => '已使用',
+            'expired'                    => '已过期',
             'validating'                 => '正在验证...',
             'validation-failed'          => '验证失败,请稍后重试',
             'create-success'             => '礼品卡创建成功',

+ 1 - 0
packages/Longyi/Gift/src/Resources/views/admin/index.blade.php

@@ -259,6 +259,7 @@
                                     >
                                         <option value="1">@lang('gift::app.admin.create.unused')</option>
                                         <option value="2">@lang('gift::app.admin.create.used')</option>
+                                        <option value="3">@lang('gift::app.admin.create.expired')</option>
                                     </x-admin::form.control-group.control>
                                     <x-admin::form.control-group.error control-name="giftcard_status" />
                                 </x-admin::form.control-group>

+ 64 - 0
packages/Longyi/Gift/src/Services/GiftCardExpiryService.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Longyi\Gift\Services;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Longyi\Gift\Models\GiftCards;
+
+/**
+ * 礼品卡过期处理。
+ *
+ * 判定规则:
+ * - 只处理「未使用」(giftcard_status = 1)的礼品卡,已使用(2)的卡不会被覆盖;
+ * - expirationdate 不为空且 <= 判定时间,才视为过期;
+ * - expirationdate 为空的卡视为永久有效,不处理;
+ * - 幂等:状态已是「已过期」(3)的卡不会再被命中,重复执行不会产生副作用。
+ */
+class GiftCardExpiryService
+{
+    /**
+     * 默认每批处理条数。
+     */
+    public const DEFAULT_BATCH_SIZE = 500;
+
+    /**
+     * 把已过期的未使用礼品卡标记为「已过期」。
+     *
+     * @param  Carbon|null  $at         过期判定基准时间,默认当前时间
+     * @param  bool         $dryRun     true 时只统计不写库
+     * @param  int          $batchSize  每批处理条数
+     * @return int  被更新(dry-run 时为被判定过期)的礼品卡数量
+     */
+    public function expire(?Carbon $at = null, bool $dryRun = false, int $batchSize = self::DEFAULT_BATCH_SIZE): int
+    {
+        $at = $at ?: now();
+
+        $batchSize = $batchSize > 0 ? $batchSize : self::DEFAULT_BATCH_SIZE;
+
+        $pending = fn () => DB::table('gift_cards')
+            ->where('giftcard_status', GiftCards::STATUS_UNUSED)
+            ->whereNotNull('expirationdate')
+            ->where('expirationdate', '<=', $at);
+
+        if ($dryRun) {
+            return (int) $pending()->count();
+        }
+
+        $total = 0;
+
+        // 按 id 分批,避免一次性锁定/更新过多行。
+        $pending()
+            ->select('id')
+            ->chunkById($batchSize, function ($rows) use (&$total, $at) {
+                $total += DB::table('gift_cards')
+                    ->whereIn('id', $rows->pluck('id')->all())
+                    ->update([
+                        'giftcard_status' => GiftCards::STATUS_EXPIRED,
+                        'updated_at'      => $at,
+                    ]);
+            });
+
+        return $total;
+    }
+}

+ 4 - 0
packages/Longyi/Gift/src/Services/GiftCardService.php

@@ -26,6 +26,10 @@ class GiftCardService
             throw new \Exception('Gift card not found.');
         }
 
+        if ($giftCard->isExpired()) {
+            throw new \Exception('Gift card has expired.');
+        }
+
         $cart = Cart::getCart();
 
         if (!empty($cart->giftcard_amount)) {