Browse Source

笔笔返可以手动发放

doge 1 day ago
parent
commit
def8fc1d71

+ 1 - 7
app/Http/Controllers/admin/Rebate.php

@@ -8,13 +8,11 @@ use App\Models\Config;
 use App\Services\BalanceLogService;
 use App\Services\RebateService;
 use App\Services\WalletService;
-use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Validation\ValidationException;
 use Exception;
-use App\Models\Rebate as RebateModel;
 
 class Rebate extends Controller
 {
@@ -26,6 +24,7 @@ class Rebate extends Controller
                 'limit' => ['nullable', 'integer', 'min:1'],
                 'member_id' => ['nullable', 'integer', 'min:1'],
                 'date' => ['nullable', 'date_format:Y-m-d'],
+                'status' => ['nullable', 'integer', 'in:0,1,2'],
                 'username' => ['nullable', 'string'],
                 'account' => ['nullable', 'string', 'max:64'],
                 'first_name' => ['nullable', 'string'],
@@ -46,13 +45,8 @@ class Rebate extends Controller
             $params = request()->validate([
                 'id' => ['required', 'integer', 'min:1'],
             ]);
-            $params['status'] = RebateModel::STATUS_WEI_FAN_YONG;
             $rebate = RebateService::findOne($params);
             if (!$rebate) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
-            $date = Carbon::now('America/New_York')->format('Y-m-d');
-            if (strtotime($rebate->date) >= strtotime($date)) {
-                throw new Exception('未到发放时间', HttpStatus::CUSTOM_ERROR);
-            }
             RebateService::BibiReturn($rebate, $rebate->effective_betting_amount);
             DB::commit();
             $this->logGrantNotice((int) $rebate->id);

+ 3 - 1
app/Models/Rebate.php

@@ -19,11 +19,13 @@ class Rebate extends BaseModel
     protected $fillable = ['date', 'member_id', 'betting_amount', 'rebate_ratio',
         'amount', 'status', 'first_name', 'username', 'audited_by',
         'profit', 'huishui_percentage', 'huishui_restriction', 'huishui_amount',
-        'effective_betting_amount', 'grant_time', 'details', 'need_flow'
+        'effective_betting_amount', 'grant_time', 'details', 'need_flow', 'granted_amount', 'granted_need_flow'
     ];
     protected $casts = ['details' => 'array'];
     const STATUS_WEI_FAN_YONG = 0;//未返佣
     const STATUS_FAN_YONG = 1; //已返佣
+    const STATUS_PARTIAL = 2; // 当天已发放一部分,后续可补发差额
+    const OPEN_STATUSES = [self::STATUS_WEI_FAN_YONG, self::STATUS_PARTIAL];
 
     public function member()
     {

+ 1 - 1
app/Services/AutomaticRewardGrantService.php

@@ -22,7 +22,7 @@ class AutomaticRewardGrantService
 
             $model = $type === 'rebate' ? Rebate::class : Backflow::class;
             $cutoff = $this->cutoff($type, $scheduled);
-            $model::whereIn('status', $type === 'rebate' ? [0] : Backflow::OPEN_STATUSES)->where('date', '<', $cutoff)
+            $model::whereIn('status', $model::OPEN_STATUSES)->where('date', '<', $cutoff)
                 ->chunkById(100, function ($rewards) use ($type, &$result) {
                     foreach ($rewards as $reward) {
                         try {

+ 17 - 2
app/Services/RebateService.php

@@ -77,7 +77,7 @@ class RebateService extends BaseService
     public static function paginate(array $search = []): array
     {
         $limit = $search['limit'] ?? 15;
-        // 当天(纽约时间)的记录也展示实时统计,但要到次日才能发放,见 RewardGrantService::grant
+        // 当天可手动提前发放,部分发放的记录继续累计。
         $today = Carbon::now('America/New_York')->format('Y-m-d');
         $paginator = static::$MODEL::where(self::getWhere($search))
             ->where('date', '<=', $today)
@@ -90,11 +90,26 @@ class RebateService extends BaseService
         $list = $paginator->items();
         foreach ($list as $item) {
             $item['account'] = (string)($item->member->account ?? '');
-            $item['can_grant'] = (int)$item->status === RebateModel::STATUS_WEI_FAN_YONG && (string)$item->date < $today;
+            foreach (self::grantFields($item->getAttributes()) as $key => $value) $item[$key] = $value;
         }
         return ['total' => $paginator->total(), 'data' => $list];
     }
 
+    public static function grantFields(array $row): array
+    {
+        $amount = bcadd((string) ($row['amount'] ?? 0), '0', 2);
+        $granted = bcadd((string) ($row['granted_amount'] ?? 0), '0', 2);
+        $open = in_array((int) ($row['status'] ?? 0), Rebate::OPEN_STATUSES, true);
+        $pending = $open && bccomp($amount, $granted, 2) > 0 ? bcsub($amount, $granted, 2) : '0.00';
+        $today = Carbon::now('America/New_York')->format('Y-m-d');
+        $date = (string) ($row['date'] ?? '');
+        return [
+            'amount' => $amount, 'granted_amount' => $granted, 'pending_amount' => $pending,
+            'can_grant' => $open && $date <= $today && (bccomp($pending, '0', 2) > 0 || $date < $today),
+            'status_text' => [0 => '未发放', 1 => '已发放', 2 => '部分发放'][(int) ($row['status'] ?? 0)] ?? '',
+        ];
+    }
+
 
     //笔笔返 根据某天的有效投注额,和反水比例,进行笔笔返
     public static function BibiReturn(Rebate $rebate, $amount): bool

+ 3 - 2
app/Services/RewardBaseSyncService.php

@@ -2,6 +2,7 @@
 
 namespace App\Services;
 
+use App\Models\Rebate;
 use App\Models\Backflow;
 use App\Services\Sharing\EffectiveBet;
 use Carbon\Carbon;
@@ -72,7 +73,7 @@ class RewardBaseSyncService
     {
         return DB::transaction(function () use ($user, $date) {
             $row = DB::table('rebates')->where('member_id', $user->member_id)->where('date', $date)->lockForUpdate()->first();
-            if ($row && (int) $row->status !== 0) return false;
+            if ($row && !in_array((int) $row->status, Rebate::OPEN_STATUSES, true)) return false;
             $reward = RewardBasisService::rebateReward(RewardBasisService::rebateBasis($user, $date), (int) ($user->level ?? 0));
             if (!$reward['has_facts'] && !$row) return false;
             $values = ['betting_amount' => $reward['betting_amount'], 'effective_betting_amount' => $reward['effective_amount'],
@@ -83,7 +84,7 @@ class RewardBaseSyncService
             if ($row) {
                 // Rows without facts are legacy PC28 days; their stored basis stays the payout basis.
                 if (!$reward['has_facts']) return false;
-                return DB::table('rebates')->where('id', $row->id)->where('status', 0)->update($values) > 0;
+                return DB::table('rebates')->where('id', $row->id)->whereIn('status', Rebate::OPEN_STATUSES)->update($values) > 0;
             }
             DB::table('rebates')->insert($values + ['member_id' => $user->member_id, 'date' => $date, 'status' => 0, 'created_at' => now()]);
             return true;

+ 15 - 25
app/Services/RewardGrantService.php

@@ -24,18 +24,17 @@ class RewardGrantService
             $model = $type === 'rebate' ? Rebate::class : Backflow::class;
             $reward = $model::whereKey($id)->lockForUpdate()->first();
             if (!$reward) throw new RuntimeException('数据不存在', HttpStatus::CUSTOM_ERROR);
-            // 回水 status 2 = 部分发放:当月提前发放过,仍可继续发放差额
-            $openStatuses = $type === 'backflow' ? [Backflow::STATUS_PENDING, Backflow::STATUS_PARTIAL] : [0];
-            if (!in_array((int) $reward->status, $openStatuses, true)) {
+            // status 2 = 周期内提前发放过,仍可继续发放新增差额。
+            if (!in_array((int) $reward->status, $model::OPEN_STATUSES, true)) {
                 if ($skipPaid) return false;
                 throw new RuntimeException('奖励已发放,无需再次发放', HttpStatus::CUSTOM_ERROR);
             }
             $cutoff = $type === 'rebate'
                 ? Carbon::now('America/New_York')->format('Y-m-d')
                 : Carbon::now('Asia/Shanghai')->format('Y-m');
-            // 回水手动发放不限月份(当月按发放时的亏损结算);笔笔返与自动发放仍须等周期结束。
-            $manualBackflow = $type === 'backflow' && $scheduledCutoff === null;
-            if (!$manualBackflow && (string) $reward->date >= $cutoff) {
+            // 手动可提前发放本期;自动仍等周期结束,笔笔返不能发放未来日期。
+            if (($scheduledCutoff !== null && (string) $reward->date >= $cutoff)
+                || ($type === 'rebate' && (string) $reward->date > $cutoff)) {
                 throw new RuntimeException('未到发放时间', HttpStatus::CUSTOM_ERROR);
             }
             if ($scheduledCutoff !== null && (string) $reward->date >= $scheduledCutoff) return false;
@@ -69,19 +68,14 @@ class RewardGrantService
             $reward->details = $result['details'];
             $reward->need_flow = $needFlow;
 
-            // 本次实际发放:笔笔返一次发完;回水只发尚未发放的差额(金额回落时不追回)
-            $pay = $amount;
-            $payFlow = $needFlow;
-            $closed = true;
-            if ($type === 'backflow') {
-                $pay = self::positive(bcsub($amount, (string) $reward->granted_amount, 2));
-                $payFlow = self::positive(bcsub($needFlow, (string) $reward->granted_need_flow, 2));
-                $closed = (string) $reward->date < $cutoff;
-                if (!$closed && bccomp($pay, '0', 2) <= 0) {
-                    throw new RuntimeException("暂无可发放金额(按当前规则应发 {$amount},已发放 " . bcadd((string) $reward->granted_amount, '0', 2) . ')', HttpStatus::CUSTOM_ERROR);
-                }
-                $remark .= ";本次发放:{$pay};累计发放:" . bcadd((string) $reward->granted_amount, $pay, 2);
+            // 每次只发新增差额,金额回落不追回;未实际入账的打码量也不能记为已发放。
+            $pay = self::positive(bcsub($amount, (string) $reward->granted_amount, 2));
+            $payFlow = bccomp($pay, '0', 2) > 0 ? self::positive(bcsub($needFlow, (string) $reward->granted_need_flow, 2)) : '0.00';
+            $closed = (string) $reward->date < $cutoff;
+            if (!$closed && bccomp($pay, '0', 2) <= 0) {
+                throw new RuntimeException("暂无可发放金额(按当前规则应发 {$amount},已发放 " . bcadd((string) $reward->granted_amount, '0', 2) . ')', HttpStatus::CUSTOM_ERROR);
             }
+            $remark .= ";本次发放:{$pay};累计发放:" . bcadd((string) $reward->granted_amount, $pay, 2);
 
             $credit = null;
             if (bccomp($pay, '0', 2) > 0) {
@@ -99,13 +93,9 @@ class RewardGrantService
                 }
             }
             $reward->amount = $amount;
-            if ($type === 'backflow') {
-                $reward->granted_amount = bcadd((string) $reward->granted_amount, $pay, 2);
-                $reward->granted_need_flow = bcadd((string) $reward->granted_need_flow, $payFlow, 2);
-                $reward->status = $closed ? Backflow::STATUS_GRANTED : Backflow::STATUS_PARTIAL;
-            } else {
-                $reward->status = 1;
-            }
+            $reward->granted_amount = bcadd((string) $reward->granted_amount, $pay, 2);
+            $reward->granted_need_flow = bcadd((string) $reward->granted_need_flow, $payFlow, 2);
+            $reward->status = $closed ? 1 : 2;
             $reward->grant_time = Carbon::now()->timestamp;
             if (!$reward->save()) throw new RuntimeException('发放失败', HttpStatus::CUSTOM_ERROR);
             if ($credit) RewardPayoutMessageService::send($credit, (string) $reward->date);

+ 28 - 0
database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::table('rebates', function (Blueprint $t) {
+            if (!Schema::hasColumn('rebates', 'granted_amount')) $t->decimal('granted_amount', 20, 2)->default(0)->comment('已发放金额');
+            if (!Schema::hasColumn('rebates', 'granted_need_flow')) $t->decimal('granted_need_flow', 20, 2)->default(0)->comment('已发放部分的打码量');
+        });
+        // 历史已发放记录保留终态,并回填累计金额,避免重复发放。
+        DB::table('rebates')->where('status', 1)->where('granted_amount', 0)
+            ->update(['granted_amount' => DB::raw('amount'), 'granted_need_flow' => DB::raw('COALESCE(need_flow, 0)')]);
+    }
+
+    public function down(): void
+    {
+        // 回退后的旧版本不支持部分发放,将其封账,避免按全额再次发放。
+        DB::table('rebates')->where('status', 2)->update(['status' => 1]);
+        Schema::table('rebates', function (Blueprint $t) {
+            $t->dropColumn(['granted_amount', 'granted_need_flow']);
+        });
+    }
+};

+ 7 - 1
docs/后端/笔笔返与回水分类规则-部署说明.md

@@ -17,7 +17,7 @@
    php artisan rewards:sync-bases --from=2026-10-24 --to=2026-11-02
    ```
 
-4. 之后调度每分钟执行 `rewards:sync-bases`(排在 `share:sync-facts --write` 之后),只更新未发放记录,不动钱包和已发放记录。
+4. 之后调度每分钟执行 `rewards:sync-bases`(排在 `share:sync-facts --write` 之后),更新未发放及部分发放记录,不动钱包和已结清记录。
 
 ## 说明
 
@@ -25,3 +25,9 @@
 - `SHARE_LOTTERY_LEGACY_MAX_ID` 范围内仅跳过没有子注的旧彩票订单。有子注的低编号订单正常同步,已被旧规则跳过的订单会在首次增量同步时补扫。没有注单事实的旧笔笔返记录仍按已存的有效投注,用「F1彩票」(`self:lottery`)分类的规则发放。
 - `huishui_restriction` 已不再参与计算,仍保留在配置表中,确认后可手动删除。
 - `BetService` 不再在下注/结算时累加 `rebates`;充值/提现仍记录到 `backflow.recharge_amount / withdrawal_amount`,仅用于展示。
+
+## 笔笔返当天提前发放(2026-11-08)
+
+上线前执行 `php artisan migrate --path=database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php --force`,再部署 `bot-28` 和 `melbet_sport-api` 的改动。新增累计金额/打码量字段;历史已发放记录回填后保留已结清状态。当天手动发放保留 `status=2`,只补新增差额,次日自动补齐并结清;每次正金额入账生成站内信。后台按钮使用 `can_grant`,前台列表支持状态2及已发/待发金额。
+
+迁移回滚会将部分发放记录封账为已发放,以避免旧代码重复发全额;尚未发放的差额不会自动补发。

+ 15 - 8
docs/总后台/回水与返佣发放设置.md

@@ -103,23 +103,22 @@ POST /admin/backflow/grant-settings
 ## 4. 自动发放的实际规则
 
 - 服务器每分钟检查一次设置,自动开关开启且到达每日设定时间后开始处理,调度精度为分钟。
-- `rebate` 只发放在**本次设定时间对应的纽约日期**之前、且 `status=0` 的日记录,沿用后台笔笔返的日口径。
-- `backflow` 只发放在**本次设定时间对应的上海月份**之前、且 `status=0` 的月记录,沿用后台回水的月口径。即使每天检查,当月记录也要等下个月才具备发放资格。
+- `rebate` 只发放在**本次设定时间对应的纽约日期**之前、且 `status` 为 `0` 或 `2` 的日记录,沿用后台笔笔返的日口径。
+- `backflow` 只发放在**本次设定时间对应的上海月份**之前、且 `status` 为 `0` 或 `2` 的月记录,沿用后台回水的月口径。即使每天检查,当月记录也要等下个月才具备发放资格。
 - 开启自动后,符合条件的**历史未发放记录也会处理**。已经发放的记录不会再次入账。
 - 当天执行时间已过时,开启开关或重启任务后,下一轮检查会补处理当天应发的记录。当天时间未到则等待设定时间。
 - 单条发放失败不影响其他记录,失败记录保留未发放状态,在后续检查中重试;成功记录不会重复发放。
 - 关闭开关或将时间改到当天尚未到达的时间后,后续自动发放停止;已在提交中的单条发放会完成,不撤回已入账奖励。
-- 回水手动发放不限月份(2026-11-05 起),当月记录可提前发,之后继续累计并可再发差额(状态“部分发放”);笔笔返手动发放仍须等当天(纽约时间)结束。
+- 回水手动发放不限月份(2026-11-05 起),当月记录可提前发,之后继续累计并可再发差额(状态“部分发放”);笔笔返手动发放可提前发放纽约当天记录,之后继续累计并仅补发新增差额;未来日期不可发放。
 - 自动模式下仍可使用原有手动发放按钮。手动与自动共用发放事务、奖励记录锁和钱包锁,重复处理同一记录不会重复入账。
 
 时间口径示例:笔笔返设为北京时间每天 `03:00`。`2026-11-01 03:00` 对应纽约 `2026-10-31`,本轮只处理 `2026-10-30` 及以前的未发放日记录;`2026-10-31` 的日记录留到后续符合条件的计划轮次。当天下午补跑仍按当天 `03:00` 的截止范围处理。
 
-奖励金额继续使用发放时配置的比例和原有计算公式:
+奖励金额在发放时按分类规则重新计算:笔笔返按有效投注和会员等级档位计算,回水按分类净亏损档位计算;本次实际入账只取累计应发金额减去累计已发金额的正差额。累计应发金额回落时不扣回已发款项。
 
-- 笔笔返:有效投注金额 × `rebate` 比例,保留两位小数。
-- 回水:`abs(充值金额 + 带符号的提现金额)` 达到 `huishui_restriction` 门槛后,乘以 `huishui_percentage` 比例,保留两位小数。
+周期结束后,无待发差额的记录会结清,但不增加余额、不生成奖励流水或站内信;本期没有新增差额时,手动发放返回“暂无可发放金额”。自动笔笔返记录的操作人标记为 `system:auto-grant`。金额、状态及发放时间通过原有 `/admin/rebate`、`/admin/backflow` 列表查看。
 
-金额为零的记录也会标记为已处理,但不增加余额、不生成正额奖励流水。自动笔笔返记录的操作人标记为 `system:auto-grant`。金额、状态及发放时间通过原有 `/admin/rebate`、`/admin/backflow` 列表查看。
+后台 `/admin/rebate` 列表新增 `granted_amount`(累计已发)、`pending_amount`(待发差额)、`status_text`。`status=2` 表示部分发放,保留发放操作;按钮是否可点击使用 `can_grant`,不要仅判断 `status=0`。手动接口仍为 `POST /admin/rebate/submit`,请求体 `{"id":123}`。重复点击没有差额时不会再次入账。
 
 ## 5. 前端交互建议
 
@@ -160,7 +159,7 @@ if (result.code === 0) {
 
 ## 7. 后端上线条件
 
-接口与任务位于 `bot-28`,不需要修改前台 `melbet_sport-api`。
+发放接口与任务位于 `bot-28`;`melbet_sport-api` 的返佣列表同步支持当天记录、部分发放状态及累计金额字段。
 
 先执行本次新增迁移,创建独立的发放设置表;迁移不会修改会员余额,也不会默认打开自动发放:
 
@@ -168,6 +167,14 @@ if (result.code === 0) {
 php artisan migrate --path=database/migrations/2026_10_25_180000_create_reward_grant_settings.php --force
 ```
 
+提前发放版本上线前先执行累计金额迁移;迁移只回填历史已发放金额,不改钱包,也不重新开放历史已结清记录:
+
+```bash
+php artisan migrate --path=database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php --force
+```
+
+迁移完成后部署两个项目的新代码。部分发放记录会继续被 `rewards:sync-bases` 更新,并在日结后由自动任务补齐差额。
+
 服务器须已有 Laravel 每分钟调度入口;若已配置,不要重复添加:
 
 ```cron

+ 88 - 2
tests/Integration/AutomaticRewardGrantTest.php

@@ -118,6 +118,7 @@ class AutomaticRewardGrantTest extends TestCase
         $this->migration()->up();
         $this->rulesMigration()->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php')->up();
         DB::table('rebate_rules')->insert(['category' => 'self:lottery', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0]);
         DB::table('backflow_rules')->insert(['category' => 'self:lottery', 'min_loss_amount' => 100, 'reward_amount' => 35, 'turnover_multiple' => 0]);
         $this->request('/admin/rebate');
@@ -684,12 +685,97 @@ class AutomaticRewardGrantTest extends TestCase
         $this->assertSame(2, DB::table('balance_logs')->where('change_type', '回水')->count());
         $this->assertSame(2, DB::table('site_messages')->count(), 'Closing without another credit must not send a notice');
 
-        // 笔笔返仍须等当天结束
-        $rebate = $this->rebate(['date' => Carbon::now('America/New_York')->format('Y-m-d')]);
+        // 未来日期仍不能提前发放。
+        $rebate = $this->rebate(['date' => Carbon::now('America/New_York')->addDay()->format('Y-m-d')]);
         $this->expectExceptionMessage('未到发放时间');
         RewardGrantService::grant('rebate', $rebate, 'operator-one');
     }
 
+    public function test_manual_rebate_pays_3033_basis_then_differences_and_auto_finishes_after_day_end(): void
+    {
+        DB::table('rebate_rules')->update(['percentage' => 1, 'turnover_multiple' => 2]);
+        $id = $this->rebate(['date' => '2026-10-25']);
+        $this->fact('1001', 'self:lottery', '2026-10-25 13:00:00', '3033', '-3033');
+        $this->enable('rebate');
+        $this->assertSame(0, $this->runRewards()['rebate']['granted'], 'Automatic payout still waits for day end');
+        $this->request('/admin/rebate/submit', ['id' => $id], 'POST');
+        $controller = new RebateController();
+        $this->assertSame(0, $controller->store()->getData(true)['code']);
+        $row = DB::table('rebates')->where('id', $id)->first();
+        $this->assertSame(2, (int) $row->status);
+        $this->assertEquals(30.33, $row->granted_amount);
+        $this->assertEquals(60.66, $row->granted_need_flow);
+        $this->assertEquals(50.33, DB::table('wallets')->value('available_balance'));
+        $this->assertFalse(RebateService::grantFields((array) $row)['can_grant']);
+        $this->assertSame(-3, $controller->store()->getData(true)['code'], 'No difference cannot pay twice');
+
+        $this->fact('1001', 'self:lottery', '2026-10-25 14:00:00', '1000', '-1000');
+        (new RewardBaseSyncService())->run('2026-10-25', '2026-10-25');
+        $fields = RebateService::grantFields((array) DB::table('rebates')->where('id', $id)->first());
+        $this->assertSame(['40.33', '30.33', '10.00', true, '部分发放'], [$fields['amount'], $fields['granted_amount'], $fields['pending_amount'], $fields['can_grant'], $fields['status_text']]);
+        DB::statement("CREATE TRIGGER reject_partial_notice BEFORE INSERT ON bot_site_message_recipients BEGIN SELECT RAISE(ABORT, 'notice unavailable'); END");
+        $this->assertNotSame(0, $controller->store()->getData(true)['code']);
+        $this->assertEquals(50.33, DB::table('wallets')->value('available_balance'));
+        $this->assertEquals(30.33, DB::table('rebates')->where('id', $id)->value('granted_amount'));
+        $this->assertSame(1, DB::table('site_messages')->count());
+        $this->assertSame(1, DB::table('wallet_bonus')->count());
+        DB::statement('DROP TRIGGER reject_partial_notice');
+        $this->assertSame(0, $controller->store()->getData(true)['code']);
+        $this->assertEquals(60.33, DB::table('wallets')->value('available_balance'));
+        $this->assertEquals(80.66, DB::table('wallet_bonus')->sum('need_flow'));
+        $this->assertSame(['30.33', '10.00'], DB::table('balance_logs')->orderBy('id')->pluck('amount')->map(fn ($v) => bcadd($v, '0', 2))->all());
+        $contents = DB::table('site_message_translations')->where('language', 'zh')->orderBy('id')->pluck('content')->all();
+        $this->assertStringContainsString('本次已发放 30.33', $contents[0]);
+        $this->assertStringContainsString('本次已发放 10.00', $contents[1]);
+
+        $this->fact('1001', 'self:lottery', '2026-10-25 14:20:00', '333', '-333');
+        (new RewardBaseSyncService())->run('2026-10-25', '2026-10-25');
+        Carbon::setTestNow(Carbon::parse('2026-10-26 14:30:00', 'Asia/Shanghai'));
+        $this->assertSame(1, $this->runRewards()['rebate']['granted']);
+        $row = DB::table('rebates')->where('id', $id)->first();
+        $this->assertSame(1, (int) $row->status);
+        $this->assertEquals(43.66, $row->granted_amount);
+        $this->assertEquals(87.32, $row->granted_need_flow);
+        $this->assertEquals(63.66, DB::table('wallets')->value('available_balance'));
+        $this->assertSame(3, DB::table('site_messages')->count());
+        $this->assertSame(0, $this->runRewards()['rebate']['granted']);
+        $this->assertSame(3, DB::table('balance_logs')->count());
+    }
+
+    public function test_rebate_day_end_without_a_difference_does_not_credit_or_notify_again(): void
+    {
+        $id = $this->rebate(['date' => '2026-10-25']);
+        $this->assertTrue(RewardGrantService::grant('rebate', $id, 'operator-one'));
+        DB::table('rebate_rules')->update(['percentage' => '0.1', 'turnover_multiple' => 3]);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 14:30:00', 'Asia/Shanghai'));
+        $this->enable('rebate');
+        $this->assertSame(1, $this->runRewards()['rebate']['granted']);
+        $row = DB::table('rebates')->where('id', $id)->first();
+        $this->assertSame(1, (int) $row->status);
+        $this->assertEquals(4, $row->granted_amount, 'A lower calculation never reclaims a previous credit');
+        $this->assertEquals(0, $row->granted_need_flow, 'Unpaid turnover must not be counted as granted');
+        $this->assertEquals(24, DB::table('wallets')->value('available_balance'));
+        $this->assertSame(1, DB::table('site_messages')->count());
+        $this->assertSame(1, DB::table('balance_logs')->count());
+        $this->assertFalse(RewardGrantService::grant('rebate', $id, 'operator-one', true));
+    }
+
+    public function test_rebate_granted_amount_migration_preserves_paid_history_and_partial_totals(): void
+    {
+        $paid = $this->rebate(['date' => '2026-09-01', 'status' => 1, 'amount' => 30.33, 'need_flow' => 60.66]);
+        $pending = $this->rebate(['date' => '2026-09-02', 'amount' => 99]);
+        $partial = $this->rebate(['date' => '2026-10-25', 'status' => 2, 'amount' => 50, 'granted_amount' => 30.33, 'granted_need_flow' => 60.66]);
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php';
+        $migration->up();
+        $this->assertEquals(30.33, DB::table('rebates')->where('id', $paid)->value('granted_amount'));
+        $this->assertEquals(60.66, DB::table('rebates')->where('id', $paid)->value('granted_need_flow'));
+        $this->assertEquals(0, DB::table('rebates')->where('id', $pending)->value('granted_amount'));
+        $this->assertEquals(30.33, DB::table('rebates')->where('id', $partial)->value('granted_amount'));
+        $migration->down();
+        $this->assertSame(1, (int) DB::table('rebates')->where('id', $partial)->value('status'));
+        $this->assertFalse(Schema::hasColumn('rebates', 'granted_amount'));
+    }
+
     public function test_granted_amount_migration_backfills_paid_rows(): void
     {
         $paid = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-08', 'status' => 1, 'amount' => '35', 'need_flow' => '70', 'granted_amount' => 0]);