Browse Source

回水:可多次发放差额,列表新增已发放/待发放/部分发放

新增 granted_amount、granted_need_flow(迁移回填已发放记录);当月手动发放后状态为部分发放并继续累计,再次发放只发差额,往月发放后结清。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doge 8 hours ago
parent
commit
3b16cbf686

+ 1 - 0
app/Http/Controllers/admin/Backflow.php

@@ -41,6 +41,7 @@ class Backflow extends Controller
                 'first_name' => ['nullable'],
                 'date' => ['nullable', 'date_format:Y-m'],
                 'username' => ['nullable', 'string'],
+                'status' => ['nullable', 'integer', 'in:0,1,2'],
             ]);
             $params['page'] = request()->input('page', 1);
             $params['limit'] = request()->input('limit', 10);

+ 6 - 1
app/Models/Backflow.php

@@ -15,9 +15,14 @@ namespace App\Models;
  */
 class Backflow extends BaseModel
 {
+    const STATUS_PENDING = 0; // 未发放
+    const STATUS_GRANTED = 1; // 已发放(月份已结束并发完)
+    const STATUS_PARTIAL = 2; // 部分发放(当月提前发放,仍在累计,可继续发放差额)
+    const OPEN_STATUSES = [self::STATUS_PENDING, self::STATUS_PARTIAL];
+
     protected $table = 'backflow';
     protected $fillable = ['date', 'member_id', 'grant_time', 'recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount', 'status',
-        'loss_amount', 'details', 'need_flow'];
+        'loss_amount', 'details', 'need_flow', 'granted_amount', 'granted_need_flow'];
     protected $casts = ['details' => 'array'];
 
     function user()

+ 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::where('status', 0)->where('date', '<', $cutoff)
+            $model::whereIn('status', $type === 'rebate' ? [0] : Backflow::OPEN_STATUSES)->where('date', '<', $cutoff)
                 ->chunkById(100, function ($rewards) use ($type, &$result) {
                     foreach ($rewards as $reward) {
                         try {

+ 21 - 3
app/Services/BackflowService.php

@@ -40,11 +40,29 @@ class BackflowService extends BaseService
         $date = Carbon::now('Asia/Shanghai')->format('Y-m');
         $field = $changeAmount > 0 ? 'recharge_amount' : 'withdrawal_amount';
         $backflow = static::$MODEL::firstOrCreate(['date' => $date, 'member_id' => $memberId]);
-        if ((int) $backflow->status === 0) $backflow->increment($field, $changeAmount);
+        if (in_array((int) $backflow->status, Backflow::OPEN_STATUSES, true)) $backflow->increment($field, $changeAmount);
         return $backflow->refresh();
     }
 
 
+    /** 已发放 / 待发放 / 是否可发放(回水可在当月多次发放差额) */
+    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), Backflow::OPEN_STATUSES, true);
+        $pending = $open && bccomp($amount, $granted, 2) > 0 ? bcsub($amount, $granted, 2) : '0.00';
+        $month = Carbon::now('Asia/Shanghai')->format('Y-m');
+        return [
+            'amount' => $amount,
+            'granted_amount' => $granted,
+            'pending_amount' => $pending,
+            // 当月需有差额才能发;往月未结清的记录即使差额为 0 也可点发放以结清
+            'can_grant' => $open && (bccomp($pending, '0', 2) > 0 || (string) ($row['date'] ?? '') < $month),
+            'status_text' => [Backflow::STATUS_PENDING => '未发放', Backflow::STATUS_GRANTED => '已发放', Backflow::STATUS_PARTIAL => '部分发放'][(int) ($row['status'] ?? 0)] ?? '',
+        ];
+    }
+
     public static function getWhere(array $search = []): array
     {
         $where = [];
@@ -78,7 +96,7 @@ class BackflowService extends BaseService
     public static function paginate(array $search = []): array
     {
 
-        // 当月(上海时间)的记录也展示实时统计,但要到下个月才能发放,见 RewardGrantService::grant
+        // 当月(上海时间)的记录也展示实时统计;后台可手动提前发放,自动发放仍等到下个月,见 RewardGrantService::grant
         $month = Carbon::now('Asia/Shanghai')->format('Y-m');
         $query = static::$MODEL::join('users', 'users.member_id', '=', 'backflow.member_id')
                 ->where(static::getWhere($search))
@@ -87,7 +105,7 @@ class BackflowService extends BaseService
         $paginator = $query->select("backflow.*", "users.first_name", "users.account", "users.admin_note as user_admin_note")->orderByDesc('date')->orderBy('status')
             ->paginate($search['limit']);
         foreach ($paginator->items() as $item) {
-            $item['can_grant'] = (int)$item->status === 0 && (string)$item->date < $month;
+            foreach (self::grantFields($item->toArray()) as $field => $value) $item[$field] = $value;
         }
 
 //        $count = $query->count();

+ 4 - 2
app/Services/RewardBaseSyncService.php

@@ -2,6 +2,7 @@
 
 namespace App\Services;
 
+use App\Models\Backflow;
 use App\Services\Sharing\EffectiveBet;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
@@ -93,12 +94,13 @@ class RewardBaseSyncService
     {
         return DB::transaction(function () use ($user, $month) {
             $row = DB::table('backflow')->where('member_id', $user->member_id)->where('date', $month)->lockForUpdate()->first();
-            if ($row && (int) $row->status !== 0) return false;
+            // 部分发放(status 2)的记录继续累计,之后可再发差额
+            if ($row && !in_array((int) $row->status, Backflow::OPEN_STATUSES, true)) return false;
             $reward = RewardBasisService::backflowReward(RewardBasisService::backflowBasis($user, $month));
             if (!$reward['has_facts'] && !$row) return false;
             $values = ['loss_amount' => $reward['loss_amount'], 'amount' => $reward['amount'], 'need_flow' => $reward['need_flow'],
                 'details' => json_encode($reward['details'], JSON_UNESCAPED_UNICODE), 'updated_at' => now()];
-            if ($row) return DB::table('backflow')->where('id', $row->id)->where('status', 0)->update($values) > 0;
+            if ($row) return DB::table('backflow')->where('id', $row->id)->whereIn('status', Backflow::OPEN_STATUSES)->update($values) > 0;
             DB::table('backflow')->insert($values + ['member_id' => $user->member_id, 'date' => $month, 'status' => 0, 'created_at' => now()]);
             return true;
         }, 3);

+ 40 - 10
app/Services/RewardGrantService.php

@@ -24,14 +24,18 @@ class RewardGrantService
             $model = $type === 'rebate' ? Rebate::class : Backflow::class;
             $reward = $model::whereKey($id)->lockForUpdate()->first();
             if (!$reward) throw new RuntimeException('数据不存在', HttpStatus::CUSTOM_ERROR);
-            if ((int) $reward->status !== 0) {
+            // 回水 status 2 = 部分发放:当月提前发放过,仍可继续发放差额
+            $openStatuses = $type === 'backflow' ? [Backflow::STATUS_PENDING, Backflow::STATUS_PARTIAL] : [0];
+            if (!in_array((int) $reward->status, $openStatuses, 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');
-            if ((string) $reward->date >= $cutoff) {
+            // 回水手动发放不限月份(当月按发放时的亏损结算);笔笔返与自动发放仍须等周期结束。
+            $manualBackflow = $type === 'backflow' && $scheduledCutoff === null;
+            if (!$manualBackflow && (string) $reward->date >= $cutoff) {
                 throw new RuntimeException('未到发放时间', HttpStatus::CUSTOM_ERROR);
             }
             if ($scheduledCutoff !== null && (string) $reward->date >= $scheduledCutoff) return false;
@@ -61,28 +65,54 @@ class RewardGrantService
                 }
             }
             $amount = $result['amount'];
+            $needFlow = $result['need_flow'];
             $reward->details = $result['details'];
-            $reward->need_flow = $result['need_flow'];
+            $reward->need_flow = $needFlow;
 
-            if (bccomp($amount, '0', 2) > 0) {
+            // 本次实际发放:笔笔返一次发完;回水只发尚未发放的差额(金额回落时不追回)
+            $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('暂无可发放金额', HttpStatus::CUSTOM_ERROR);
+                }
+                $remark .= ";本次发放:{$pay};累计发放:" . bcadd((string) $reward->granted_amount, $pay, 2);
+            }
+
+            if (bccomp($pay, '0', 2) > 0) {
                 $wallet = Wallet::where('member_id', $reward->member_id)->lockForUpdate()->first();
                 if (!$wallet) throw new RuntimeException('会员钱包不存在', HttpStatus::CUSTOM_ERROR);
                 $before = (string) $wallet->available_balance;
-                $wallet->available_balance = bcadd($before, $amount, 10);
+                $wallet->available_balance = bcadd($before, $pay, 10);
                 if (!$wallet->save()) throw new RuntimeException('余额更新失败', HttpStatus::CUSTOM_ERROR);
-                BalanceLogService::addLog($reward->member_id, $amount, $before, $wallet->available_balance,
+                BalanceLogService::addLog($reward->member_id, $pay, $before, $wallet->available_balance,
                     $changeType, $reward->id, $remark);
-                if (bccomp($result['need_flow'], '0', 2) > 0) {
-                    WalletBonus::addData($reward->member_id, $type === 'rebate' ? 4 : 5, $amount, 0, $reward->id, $result['need_flow']);
+                if (bccomp($payFlow, '0', 2) > 0) {
+                    WalletBonus::addData($reward->member_id, $type === 'rebate' ? 4 : 5, $pay, 0, $reward->id, $payFlow);
                     // 规则配置了打码倍数时计入提现打码量
-                    WageringService::addRequirement($wallet, $result['need_flow'], 'activity:' . $changeType, $reward->id, $remark);
+                    WageringService::addRequirement($wallet, $payFlow, 'activity:' . $changeType, $reward->id, $remark);
                 }
             }
             $reward->amount = $amount;
-            $reward->status = 1;
+            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->grant_time = Carbon::now()->timestamp;
             if (!$reward->save()) throw new RuntimeException('发放失败', HttpStatus::CUSTOM_ERROR);
             return true;
         }, 3);
     }
+
+    private static function positive(string $value): string
+    {
+        return bccomp($value, '0', 2) > 0 ? $value : '0.00';
+    }
 }

+ 32 - 0
database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** 回水可多次发放:记录已发放金额/打码量;status 2 = 部分发放(当月提前发放后仍在累计)。 */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('backflow')) return;
+        Schema::table('backflow', function (Blueprint $t) {
+            if (!Schema::hasColumn('backflow', 'granted_amount')) $t->decimal('granted_amount', 20, 2)->default(0)->comment('已发放金额');
+            if (!Schema::hasColumn('backflow', 'granted_need_flow')) $t->decimal('granted_need_flow', 20, 2)->default(0)->comment('已发放部分的打码量');
+        });
+        // 已发放的历史记录:发放时的金额即为已发放金额
+        DB::table('backflow')->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
+    {
+        if (!Schema::hasTable('backflow')) return;
+        DB::table('backflow')->where('status', 2)->update(['status' => 1]);
+        Schema::table('backflow', function (Blueprint $t) {
+            foreach (['granted_amount', 'granted_need_flow'] as $column) {
+                if (Schema::hasColumn('backflow', $column)) $t->dropColumn($column);
+            }
+        });
+    }
+};

+ 1 - 0
docs/前端交付/bot-28/后台/README.md

@@ -24,6 +24,7 @@
 | 赛事列表:按是否置顶筛选(`is_rec`,2026-11-05) | [赛事列表-置顶筛选](赛事列表-置顶筛选.md) |
 | 在线充值订单:每行“拒绝”按钮,订单直接变为失败(2026-11-05) | [在线充值订单拒绝-前端接入](在线充值订单拒绝-前端接入.md) |
 | 充值/提现通道:操作日志(谁、什么时间、改了什么),移除 data_type=3 活动配置与层级 activityTypes(2026-11-05) | [充值提现通道-操作日志与移除活动配置](充值提现通道-操作日志与移除活动配置.md) |
+| 回水:当月可提前发放并继续发放差额,列表新增已发放、待发放、部分发放状态(2026-11-05) | [回水-已发放与继续发放](回水-已发放与继续发放-前端接入.md) |
 | 资金流动:区间总计与每日列表 | [资金流动-前端接入](资金流动-前端接入.md) |
 | 用户报表:玩过的游戏大类、盈亏(充值-提现)与充值/提现/盈亏/余额排序(2026-11-04) | [用户报表-游戏分类、盈亏与排序](用户报表-游戏分类与提充差值.md) |
 | 龙虎榜:五类排名与分页 | [龙虎榜-前端接入](龙虎榜-前端接入.md) |

+ 101 - 0
docs/前端交付/bot-28/后台/回水-已发放与继续发放-前端接入.md

@@ -0,0 +1,101 @@
+# 回水:已发放/待发放/继续发放(前端接入)
+
+接口项目:`bot-28`,总后台 API 域名,`Authorization: Bearer <admin_token>`,成功 `code=0`。
+
+页面:回水列表 `/admin/backflow`。
+
+## 背景
+
+回水可以在**当月**手动提前发放。发放后会员继续投注,当月亏损还会增加、回水金额会变大,此时可以**继续发放差额**。列表新增字段,用来显示已经发了多少、还有多少没发。
+
+## 一、列表 `GET /admin/backflow`
+
+### 新增请求参数
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `status` | int | 否 | `0` 未发放,`1` 已发放,`2` 部分发放;不传为全部 |
+
+其他参数不变(`page`、`limit`、`member_id`、`account`、`first_name`、`date`)。
+
+### 新增/变更返回字段(`data.data[]` 每行)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `amount` | string | **应发总额**:按当前亏损与规则算出的本月回水,两位小数。未结清的记录会随注单实时更新 |
+| `granted_amount` | string | **已发放**:累计已打入会员钱包的金额 |
+| `pending_amount` | string | **待发放**:`amount − granted_amount`,最小 `0.00`;已发放(`status=1`)的记录恒为 `0.00` |
+| `status` | int | `0` 未发放/`1` 已发放/`2` 部分发放(新增) |
+| `status_text` | string | `未发放`/`已发放`/`部分发放`,可直接显示 |
+| `can_grant` | bool | 是否显示可点击的“发放”按钮,见下方规则 |
+| `grant_time` | string | **最近一次**发放时间,`Y-m-d H:i`;未发放为空字符串 |
+
+```json
+{
+  "id": 12,
+  "date": "2026-11",
+  "member_id": "1001",
+  "account": "alice",
+  "amount": "50.00",
+  "granted_amount": "8.00",
+  "pending_amount": "42.00",
+  "status": 2,
+  "status_text": "部分发放",
+  "can_grant": true,
+  "grant_time": "2026-11-05 16:20",
+  "loss_amount": "350.00",
+  "need_flow": "50.00",
+  "details": [ ... ]
+}
+```
+
+### 状态说明
+
+| status | 含义 | 何时出现 |
+|---|---|---|
+| 0 未发放 | 还没发过 | 新记录 |
+| 2 部分发放 | 当月已提前发过,月份未结束,仍在累计 | 当月记录手动发放后 |
+| 1 已发放 | 月份已结束且已结清,不再变化 | 往月记录发放后(手动或自动) |
+
+### `can_grant` 规则
+
+- `status=1`:`false`。
+- 当月记录(`status` 为 0 或 2):`pending_amount > 0` 时为 `true`;没有差额时为 `false`。
+- 往月记录(`status` 为 0 或 2):`true`。即使差额为 0,点“发放”也会把记录结清为“已发放”。
+
+### 建议展示
+
+列:月份 | 会员 | 亏损 | 应发总额 `amount` | 已发放 `granted_amount` | 待发放 `pending_amount` | 状态 `status_text` | 最近发放时间 | 操作。
+
+操作列:`can_grant=true` 显示“发放”,否则隐藏或置灰。当 `status=2` 时按钮文字建议显示“继续发放”。
+
+## 二、发放 `POST /admin/backflow/grant`
+
+接口和参数不变:`{ "id": 12 }`。权限 URI 仍为 `admin/backflow/grant`。
+
+每次点击只发**当前差额**:
+
+1. 发放时先按最新注单和规则重算 `amount`。
+2. 本次发放金额 = `amount − granted_amount`(最小为 0)。
+3. 打码量同理只加差额部分。
+4. 当月记录发放后变为 `status=2`,往月记录发放后变为 `status=1`。
+
+例:当月亏 50,档位“亏 10 奖 8”,点发放 → 发 8,`已发放 8`。之后亏到 350,命中“亏 300 奖 50” → 列表显示 `应发 50 / 已发 8 / 待发 42`,再点发放 → 发 42,`已发放 50`。
+
+### 错误提示(`code≠0`,直接显示 `msg`)
+
+| msg | 情况 |
+|---|---|
+| `暂无可发放金额` | 当月记录没有差额(`can_grant=false` 时不应能点到) |
+| `奖励已发放,无需再次发放` | `status=1` 的记录 |
+| `数据不存在` | id 错误 |
+
+### 其他说明
+
+- 会员钱包每次发放都有一条“回水”资金记录,金额为本次发放的差额;备注末尾带 `本次发放:x;累计发放:y`。会员彩金页(`/admin/balance/log?is_fancai=1`,类型“回水”)可查到每一笔。
+- 应发金额减少(会员后来赢回)时,已发的不追回,`pending_amount` 显示 `0.00`。
+- 自动发放只处理往月记录:月份结束后按设定时间自动发放剩余差额,并结清为“已发放”。
+
+## 三、会员前台(melbet)
+
+会员回水列表(`BackflowController`,`/api/backflow/lists`)新增 `granted_amount`、`pending_amount`,`status` 可能为 `2`(部分发放),筛选参数 `status` 也接受 `2`。前台列表仍只显示往月记录。

+ 101 - 0
docs/总后台/回水-已发放与继续发放-前端接入.md

@@ -0,0 +1,101 @@
+# 回水:已发放/待发放/继续发放(前端接入)
+
+接口项目:`bot-28`,总后台 API 域名,`Authorization: Bearer <admin_token>`,成功 `code=0`。
+
+页面:回水列表 `/admin/backflow`。
+
+## 背景
+
+回水可以在**当月**手动提前发放。发放后会员继续投注,当月亏损还会增加、回水金额会变大,此时可以**继续发放差额**。列表新增字段,用来显示已经发了多少、还有多少没发。
+
+## 一、列表 `GET /admin/backflow`
+
+### 新增请求参数
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `status` | int | 否 | `0` 未发放,`1` 已发放,`2` 部分发放;不传为全部 |
+
+其他参数不变(`page`、`limit`、`member_id`、`account`、`first_name`、`date`)。
+
+### 新增/变更返回字段(`data.data[]` 每行)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `amount` | string | **应发总额**:按当前亏损与规则算出的本月回水,两位小数。未结清的记录会随注单实时更新 |
+| `granted_amount` | string | **已发放**:累计已打入会员钱包的金额 |
+| `pending_amount` | string | **待发放**:`amount − granted_amount`,最小 `0.00`;已发放(`status=1`)的记录恒为 `0.00` |
+| `status` | int | `0` 未发放/`1` 已发放/`2` 部分发放(新增) |
+| `status_text` | string | `未发放`/`已发放`/`部分发放`,可直接显示 |
+| `can_grant` | bool | 是否显示可点击的“发放”按钮,见下方规则 |
+| `grant_time` | string | **最近一次**发放时间,`Y-m-d H:i`;未发放为空字符串 |
+
+```json
+{
+  "id": 12,
+  "date": "2026-11",
+  "member_id": "1001",
+  "account": "alice",
+  "amount": "50.00",
+  "granted_amount": "8.00",
+  "pending_amount": "42.00",
+  "status": 2,
+  "status_text": "部分发放",
+  "can_grant": true,
+  "grant_time": "2026-11-05 16:20",
+  "loss_amount": "350.00",
+  "need_flow": "50.00",
+  "details": [ ... ]
+}
+```
+
+### 状态说明
+
+| status | 含义 | 何时出现 |
+|---|---|---|
+| 0 未发放 | 还没发过 | 新记录 |
+| 2 部分发放 | 当月已提前发过,月份未结束,仍在累计 | 当月记录手动发放后 |
+| 1 已发放 | 月份已结束且已结清,不再变化 | 往月记录发放后(手动或自动) |
+
+### `can_grant` 规则
+
+- `status=1`:`false`。
+- 当月记录(`status` 为 0 或 2):`pending_amount > 0` 时为 `true`;没有差额时为 `false`。
+- 往月记录(`status` 为 0 或 2):`true`。即使差额为 0,点“发放”也会把记录结清为“已发放”。
+
+### 建议展示
+
+列:月份 | 会员 | 亏损 | 应发总额 `amount` | 已发放 `granted_amount` | 待发放 `pending_amount` | 状态 `status_text` | 最近发放时间 | 操作。
+
+操作列:`can_grant=true` 显示“发放”,否则隐藏或置灰。当 `status=2` 时按钮文字建议显示“继续发放”。
+
+## 二、发放 `POST /admin/backflow/grant`
+
+接口和参数不变:`{ "id": 12 }`。权限 URI 仍为 `admin/backflow/grant`。
+
+每次点击只发**当前差额**:
+
+1. 发放时先按最新注单和规则重算 `amount`。
+2. 本次发放金额 = `amount − granted_amount`(最小为 0)。
+3. 打码量同理只加差额部分。
+4. 当月记录发放后变为 `status=2`,往月记录发放后变为 `status=1`。
+
+例:当月亏 50,档位“亏 10 奖 8”,点发放 → 发 8,`已发放 8`。之后亏到 350,命中“亏 300 奖 50” → 列表显示 `应发 50 / 已发 8 / 待发 42`,再点发放 → 发 42,`已发放 50`。
+
+### 错误提示(`code≠0`,直接显示 `msg`)
+
+| msg | 情况 |
+|---|---|
+| `暂无可发放金额` | 当月记录没有差额(`can_grant=false` 时不应能点到) |
+| `奖励已发放,无需再次发放` | `status=1` 的记录 |
+| `数据不存在` | id 错误 |
+
+### 其他说明
+
+- 会员钱包每次发放都有一条“回水”资金记录,金额为本次发放的差额;备注末尾带 `本次发放:x;累计发放:y`。会员彩金页(`/admin/balance/log?is_fancai=1`,类型“回水”)可查到每一笔。
+- 应发金额减少(会员后来赢回)时,已发的不追回,`pending_amount` 显示 `0.00`。
+- 自动发放只处理往月记录:月份结束后按设定时间自动发放剩余差额,并结清为“已发放”。
+
+## 三、会员前台(melbet)
+
+会员回水列表(`BackflowController`,`/api/backflow/lists`)新增 `granted_amount`、`pending_amount`,`status` 可能为 `2`(部分发放),筛选参数 `status` 也接受 `2`。前台列表仍只显示往月记录。

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

@@ -109,6 +109,7 @@ POST /admin/backflow/grant-settings
 - 当天执行时间已过时,开启开关或重启任务后,下一轮检查会补处理当天应发的记录。当天时间未到则等待设定时间。
 - 单条发放失败不影响其他记录,失败记录保留未发放状态,在后续检查中重试;成功记录不会重复发放。
 - 关闭开关或将时间改到当天尚未到达的时间后,后续自动发放停止;已在提交中的单条发放会完成,不撤回已入账奖励。
+- 回水手动发放不限月份(2026-11-05 起),当月记录可提前发,之后继续累计并可再发差额(状态“部分发放”);笔笔返手动发放仍须等当天(纽约时间)结束。
 - 自动模式下仍可使用原有手动发放按钮。手动与自动共用发放事务、奖励记录锁和钱包锁,重复处理同一记录不会重复入账。
 
 时间口径示例:笔笔返设为北京时间每天 `03:00`。`2026-11-01 03:00` 对应纽约 `2026-10-31`,本轮只处理 `2026-10-30` 及以前的未发放日记录;`2026-10-31` 的日记录留到后续符合条件的计划轮次。当天下午补跑仍按当天 `03:00` 的截止范围处理。

+ 2 - 2
docs/总后台/回水规则配置.md

@@ -102,7 +102,7 @@ Content-Type: application/json
 | `loss_amount` | 各分类亏损合计 |
 | `details` | 分类明细数组,每项见下表 |
 | `need_flow` | 需要打码量合计 |
-| `can_grant` | 是否可点“发放”(2026-11-05 新增):未发放且月份早于当月(上海时间)才为 `true` |
+| `can_grant` | 是否可点“发放”(2026-11-05 新增):未发放即为 `true`,包括当月 |
 
 `details` 每项:
 
@@ -121,5 +121,5 @@ Content-Type: application/json
 
 - `recharge_amount`、`withdrawal_amount` 仍会记录,仅供展示,不再参与回水计算。
 - 未发放记录的 `amount` 是按当前规则的预估值,发放时会按最新注单和规则重新计算。
-- **2026-11-05 起列表包含当月**(上海时间):投注结算后约 1~2 分钟出现当月记录,`amount` 为实时预估,随注单变化更新;当月记录 `can_grant=false`,前端隐藏或置灰“发放”按钮,下个月才能发放(手动点发放会返回“未到发放时间”)。
+- **2026-11-05 起列表包含当月**(上海时间):投注结算后约 1~2 分钟出现当月记录,`amount` 为实时预估,随注单变化更新;当月记录也可以在后台**手动**发放;发放后记录继续累计,之后可继续发放差额,详见《回水-已发放与继续发放-前端接入》。**自动发放**仍只发上个月及更早的记录。
 - 规则按游戏分类匹配。F1 自有彩票(六合彩、极速赛车、极速飞艇、28 等)都归在 **F1彩票**(`self:lottery`)分类,需在该分类下配置亏损档位;配在第三方“彩票”(`third:3`)下不会命中。

+ 19 - 8
tests/Integration/AdminListAccountTest.php

@@ -40,7 +40,8 @@ class AdminListAccountTest extends TestCase
             $t->decimal('betting_amount', 20, 2)->default(0); $t->timestamps();
         });
         $schema->create('backflow', function (Blueprint $t) {
-            $t->id(); $t->string('member_id'); $t->string('date'); $t->integer('status')->default(0); $t->timestamps();
+            $t->id(); $t->string('member_id'); $t->string('date'); $t->integer('status')->default(0);
+            $t->decimal('amount', 20, 2)->default(0); $t->decimal('granted_amount', 20, 2)->default(0); $t->timestamps();
         });
         $schema->create('recharges', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->string('coin')->default('CNY'); $t->decimal('amount', 20, 2)->default(0);
@@ -99,21 +100,31 @@ class AdminListAccountTest extends TestCase
         $this->assertSame(['m2' => 'bob'], $this->accounts(BackflowService::paginate(['limit' => 10, 'account' => 'bo'])['data']));
     }
 
-    public function test_backflow_list_shows_the_current_month_but_only_past_unpaid_months_can_be_granted(): void
+    public function test_backflow_list_shows_granted_pending_and_whether_more_can_be_granted(): void
     {
         \Carbon\Carbon::setTestNow(\Carbon\Carbon::parse('2026-11-05 15:00:00', 'Asia/Shanghai'));
         try {
             DB::table('backflow')->insert([
-                ['member_id' => 'm1', 'date' => '2026-11', 'status' => 0],
-                ['member_id' => 'm2', 'date' => '2026-10', 'status' => 0],
-                ['member_id' => 'm3', 'date' => '2026-10', 'status' => 1],
-                ['member_id' => 'm1', 'date' => '2026-12', 'status' => 0],
+                ['member_id' => 'm1', 'date' => '2026-11', 'status' => 0, 'amount' => 8, 'granted_amount' => 0],   // 当月未发
+                ['member_id' => 'm2', 'date' => '2026-11', 'status' => 2, 'amount' => 50, 'granted_amount' => 8],  // 当月部分发放,可补发
+                ['member_id' => 'm3', 'date' => '2026-11', 'status' => 2, 'amount' => 8, 'granted_amount' => 8],   // 当月已发完,暂无差额
+                ['member_id' => 'm1', 'date' => '2026-10', 'status' => 0, 'amount' => 0, 'granted_amount' => 0],   // 往月未结清
+                ['member_id' => 'm2', 'date' => '2026-10', 'status' => 1, 'amount' => 35, 'granted_amount' => 35], // 已发放
+                ['member_id' => 'm1', 'date' => '2026-12', 'status' => 0, 'amount' => 0, 'granted_amount' => 0],   // 未来月份不展示
             ]);
             $rows = [];
             foreach (json_decode(json_encode(BackflowService::paginate(['limit' => 10])['data']), true) as $row) {
-                $rows[$row['member_id'] . '|' . $row['date']] = $row['can_grant'];
+                $rows[$row['member_id'] . '|' . $row['date']] = [$row['granted_amount'], $row['pending_amount'], $row['can_grant'], $row['status_text']];
             }
-            $this->assertSame(['m1|2026-11' => false, 'm2|2026-10' => true, 'm3|2026-10' => false], $rows);
+            ksort($rows);
+            $this->assertSame([
+                'm1|2026-10' => ['0.00', '0.00', true, '未发放'],
+                'm1|2026-11' => ['0.00', '8.00', true, '未发放'],
+                'm2|2026-10' => ['35.00', '0.00', false, '已发放'],
+                'm2|2026-11' => ['8.00', '42.00', true, '部分发放'],
+                'm3|2026-11' => ['8.00', '0.00', false, '部分发放'],
+            ], $rows);
+            $this->assertSame(2, BackflowService::paginate(['limit' => 10, 'status' => 2])['total']);
         } finally {
             \Carbon\Carbon::setTestNow();
         }

+ 60 - 0
tests/Integration/AutomaticRewardGrantTest.php

@@ -108,6 +108,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();
         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');
@@ -536,6 +537,65 @@ class AutomaticRewardGrantTest extends TestCase
         $this->assertEquals(120, DB::table('backflow')->where('id', $row->id)->value('need_flow'));
     }
 
+    public function test_backflow_can_be_granted_again_for_the_remainder_until_the_month_is_closed(): void
+    {
+        // now = 2026-10-25 14:30 上海;当月 2026-10。钱包初始 20。
+        DB::table('backflow_rules')->insert([
+            ['category' => 'self:lottery', 'min_loss_amount' => 10, 'reward_amount' => 8, 'turnover_multiple' => 1],
+            ['category' => 'self:lottery', 'min_loss_amount' => 300, 'reward_amount' => 50, 'turnover_multiple' => 1],
+        ]);
+        $id = $this->backflow(['date' => '2026-10', 'recharge_amount' => '50.00', 'withdrawal_amount' => '0']); // 亏 50
+        $this->enable('backflow');
+        $this->assertSame(0, $this->runRewards()['backflow']['granted'], '自动发放不发当月');
+
+        // 第一次手动发放:当月亏 50 → 8,记为部分发放
+        $this->assertTrue(RewardGrantService::grant('backflow', $id, 'operator-one'));
+        $row = DB::table('backflow')->where('id', $id)->first();
+        $this->assertSame([2, '8.00', '8.00'], [(int) $row->status, bcadd($row->amount, 0, 2), bcadd($row->granted_amount, 0, 2)]);
+        $this->assertEquals(28, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertSame(['amount' => '8.00', 'granted_amount' => '8.00', 'pending_amount' => '0.00', 'can_grant' => false, 'status_text' => '部分发放'],
+            BackflowService::grantFields((array) $row));
+
+        // 没有差额时当月不能再发
+        try { RewardGrantService::grant('backflow', $id, 'operator-one'); $this->fail('expected exception'); }
+        catch (\RuntimeException $e) { $this->assertSame('暂无可发放金额', $e->getMessage()); }
+
+        // 继续亏到 350:同步后应发 50,已发 8,待发 42
+        $this->fact('1001', 'self:lottery', '2026-10-20 12:00:00', '300', '-300');
+        (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20');
+        $fields = BackflowService::grantFields((array) DB::table('backflow')->where('id', $id)->first());
+        $this->assertSame(['50.00', '8.00', '42.00', true], [$fields['amount'], $fields['granted_amount'], $fields['pending_amount'], $fields['can_grant']]);
+
+        $this->assertTrue(RewardGrantService::grant('backflow', $id, 'operator-two'));
+        $row = DB::table('backflow')->where('id', $id)->first();
+        $this->assertSame([2, '50.00', '50.00'], [(int) $row->status, bcadd($row->granted_amount, 0, 2), bcadd($row->granted_need_flow, 0, 2)]);
+        $this->assertEquals(70, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertSame(['8.0000000000', '42.0000000000'], DB::table('balance_logs')->where('change_type', '回水')->orderBy('id')->pluck('amount')
+            ->map(fn ($v) => bcadd($v, 0, 10))->all());
+        $this->assertStringContainsString('本次发放:42.00;累计发放:50.00', DB::table('balance_logs')->where('change_type', '回水')->orderByDesc('id')->value('remark'));
+
+        // 下个月自动发放:无差额,只结清为已发放,不重复入账
+        Carbon::setTestNow(Carbon::parse('2026-11-01 14:30:00', 'Asia/Shanghai'));
+        $this->assertSame(1, $this->runRewards()['backflow']['granted']);
+        $this->assertSame(1, (int) DB::table('backflow')->where('id', $id)->value('status'));
+        $this->assertEquals(70, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertSame(2, DB::table('balance_logs')->where('change_type', '回水')->count());
+
+        // 笔笔返仍须等当天结束
+        $rebate = $this->rebate(['date' => Carbon::now('America/New_York')->format('Y-m-d')]);
+        $this->expectExceptionMessage('未到发放时间');
+        RewardGrantService::grant('rebate', $rebate, 'operator-one');
+    }
+
+    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]);
+        $open = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-07', 'status' => 0, 'amount' => '35', 'granted_amount' => 0]);
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php')->up();
+        $this->assertEquals([35, 70], array_values((array) DB::table('backflow')->where('id', $paid)->first(['granted_amount', 'granted_need_flow'])));
+        $this->assertEquals(0, DB::table('backflow')->where('id', $open)->value('granted_amount'));
+    }
+
     public function test_sync_cursor_only_revisits_changed_facts_and_backfill_range_creates_missing_rows(): void
     {
         $this->fact('3001', 'third:1', '2026-10-20 13:00:00', '100', '-100', ['updated_at' => '2026-10-20 13:00:00']);