doge hace 2 días
padre
commit
f0832062ed

+ 1 - 1
app/Console/Kernel.php

@@ -26,7 +26,6 @@ class Kernel extends ConsoleKernel
         //      ->onOneServer();       // 如果在多服务器环境
 
         $schedule->command('sport')->dailyAt('23:59:00');
-        $schedule->command('rewards:auto-grant')->everyMinute()->withoutOverlapping(60);
         $schedule->command('messages:dispatch')->everyMinute()->withoutOverlapping(10);
         $schedule->command('reporting:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('promotions:sync')->everyMinute()->withoutOverlapping(30);
@@ -47,6 +46,7 @@ class Kernel extends ConsoleKernel
         $schedule->command('report:member-daily ' . now()->subDay()->toDateString() . ' --days=3')
             ->dailyAt('04:30')->withoutOverlapping(60);
         $schedule->command('rewards:sync-bases')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('rewards:auto-grant')->everyMinute()->withoutOverlapping(60);
         $schedule->command('wagering:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);
         $schedule->command('share:settle')->timezone('Asia/Shanghai')->dailyAt('00:10')->withoutOverlapping(180);

+ 2 - 3
app/Http/Controllers/admin/Backflow.php

@@ -9,7 +9,6 @@ use Exception;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\ValidationException;
-use Carbon\Carbon;
 
 class Backflow extends Controller
 {
@@ -39,7 +38,7 @@ class Backflow extends Controller
                 'member_id' => ['nullable', 'string'],
                 'account' => ['nullable', 'string', 'max:64'],
                 'first_name' => ['nullable'],
-                'date' => ['nullable', 'date_format:Y-m'],
+                'date' => ['nullable', 'date_format:Y-m-d,Y-m'],
                 'username' => ['nullable', 'string'],
                 'status' => ['nullable', 'integer', 'in:0,1,2'],
             ]);
@@ -53,4 +52,4 @@ class Backflow extends Controller
         }
         return $this->success($result);
     }
-}
+}

+ 2 - 1
app/Models/Backflow.php

@@ -21,6 +21,7 @@ class Backflow extends BaseModel
     const OPEN_STATUSES = [self::STATUS_PENDING, self::STATUS_PARTIAL];
 
     protected $table = 'backflow';
+    protected $hidden = ['daily_date'];
     protected $fillable = ['date', 'member_id', 'grant_time', 'recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount', 'status',
         'loss_amount', 'details', 'need_flow', 'granted_amount', 'granted_need_flow'];
     protected $casts = ['details' => 'array'];
@@ -36,4 +37,4 @@ class Backflow extends BaseModel
         if ($value > 0) return date('Y-m-d H:i', $value);
         return "";
     }
-}
+}

+ 8 - 2
app/Services/AutomaticRewardGrantService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Models\Backflow;
 use App\Models\Rebate;
+use App\Support\BackflowPeriod;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
@@ -22,7 +23,12 @@ class AutomaticRewardGrantService
 
             $model = $type === 'rebate' ? Rebate::class : Backflow::class;
             $cutoff = $this->cutoff($type, $scheduled);
-            $model::whereIn('status', $model::OPEN_STATUSES)->where('date', '<', $cutoff)
+            $query = $model::whereIn('status', $model::OPEN_STATUSES)->where('date', '<', $cutoff);
+            if ($type === 'backflow') {
+                $dailyFrom = BackflowPeriod::dailyFrom();
+                if ($dailyFrom === null || $cutoff < $dailyFrom) $query->where('date', '<', substr($cutoff, 0, 7));
+            }
+            $query
                 ->chunkById(100, function ($rewards) use ($type, &$result) {
                     foreach ($rewards as $reward) {
                         try {
@@ -49,6 +55,6 @@ class AutomaticRewardGrantService
     private function cutoff(string $type, \Carbon\CarbonInterface $scheduled): string
     {
         return $type === 'rebate' ? $scheduled->copy()->setTimezone('America/New_York')->format('Y-m-d')
-            : $scheduled->copy()->setTimezone('Asia/Shanghai')->format('Y-m');
+            : $scheduled->copy()->setTimezone(BackflowPeriod::TIMEZONE)->toDateString();
     }
 }

+ 24 - 15
app/Services/BackflowService.php

@@ -3,6 +3,7 @@
 namespace App\Services;
 
 use App\Models\Backflow;
+use App\Support\BackflowPeriod;
 use Carbon\Carbon;
 use Exception;
 
@@ -30,35 +31,40 @@ class BackflowService extends BaseService
 
 
     /**
-     * 只记录当月充值/提现,回水金额由 rewards:sync-bases 按分类亏损和规则计算。
+     * 记录本期充值/提现供列表展示,回水金额按分类亏损和规则计算。
      * @param $memberId string 会员ID
      * @param  $changeAmount float 充值或提现金额
      * @return Backflow
      */
     public static function updateOrCreate(string $memberId, float $changeAmount): Backflow
     {
-        $date = Carbon::now('Asia/Shanghai')->format('Y-m');
+        $date = BackflowPeriod::forDate(Carbon::now(BackflowPeriod::TIMEZONE)->toDateString(), BackflowPeriod::dailyFrom());
         $field = $changeAmount > 0 ? 'recharge_amount' : 'withdrawal_amount';
-        $backflow = static::$MODEL::firstOrCreate(['date' => $date, 'member_id' => $memberId]);
-        if (in_array((int) $backflow->status, Backflow::OPEN_STATUSES, true)) $backflow->increment($field, $changeAmount);
+        $query = static::$MODEL::query();
+        if (strlen($date) === 10) $query->where('daily_date', $date);
+        $backflow = $query->firstOrCreate(['date' => $date, 'member_id' => $memberId]);
+        static::$MODEL::whereKey($backflow->id)->whereIn('status', Backflow::OPEN_STATUSES)->increment($field, $changeAmount);
         return $backflow->refresh();
     }
 
 
-    /** 已发放 / 待发放 / 是否可发放(回水可在当月多次发放差额) */
-    public static function grantFields(array $row): array
+    /** 已发放 / 待发放 / 是否可发放 */
+    public static function grantFields(array $row, ?string $dailyFrom = null, bool $hasUnsettled = false): 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');
+        $period = (string) ($row['date'] ?? '');
+        $today = Carbon::now(BackflowPeriod::TIMEZONE)->toDateString();
+        [, $end] = $period !== '' ? BackflowPeriod::bounds($period, $dailyFrom) : ['', $today];
+        $closed = $period !== '' && $today >= $end;
+        $waiting = $closed && $hasUnsettled && BackflowPeriod::waitsForSettlement($period, $dailyFrom);
         return [
             'amount' => $amount,
             'granted_amount' => $granted,
             'pending_amount' => $pending,
-            // 当月需有差额才能发;往月未结清的记录即使差额为 0 也可点发放以结清
-            'can_grant' => $open && (bccomp($pending, '0', 2) > 0 || (string) ($row['date'] ?? '') < $month),
+            'can_grant' => $open && !$waiting && ($closed || bccomp($pending, '0', 2) > 0),
             'status_text' => [Backflow::STATUS_PENDING => '未发放', Backflow::STATUS_GRANTED => '已发放', Backflow::STATUS_PARTIAL => '部分发放'][(int) ($row['status'] ?? 0)] ?? '',
         ];
     }
@@ -75,7 +81,8 @@ class BackflowService extends BaseService
         }
 
         if (isset($search['date']) && !empty($search['date'])) {
-            $where[] = ['backflow.date', '=', $search['date']];
+            $period = $search['date'];
+            $where[] = strlen($period) === 7 ? ['backflow.date', 'like', $period . '%'] : ['backflow.date', '=', $period];
         }
 
 
@@ -96,11 +103,11 @@ class BackflowService extends BaseService
     public static function paginate(array $search = []): array
     {
 
-        // 当月(上海时间)的记录也展示实时统计;后台可手动提前发放,自动发放仍等到下个月,见 RewardGrantService::grant
-        $month = Carbon::now('Asia/Shanghai')->format('Y-m');
+        $today = Carbon::now(BackflowPeriod::TIMEZONE)->toDateString();
+        $dailyFrom = BackflowPeriod::dailyFrom();
         $query = static::$MODEL::join('users', 'users.member_id', '=', 'backflow.member_id')
                 ->where(static::getWhere($search))
-                ->where('backflow.date', '<=', $month);
+                ->where('backflow.date', '<=', $today);
         \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
 
         $paginator = $query->select("backflow.*", "users.first_name", "users.account", "users.admin_note as user_admin_note")->orderByDesc('date')->orderBy('status')
@@ -108,18 +115,20 @@ class BackflowService extends BaseService
         // 未结清的记录按最新注单和当前规则实时重算(规则修改后存储值可能过期),与点“发放”时的计算一致
         $tiers = $labels = null;
         foreach ($paginator->items() as $item) {
+            $hasUnsettled = false;
             if (in_array((int) $item->status, Backflow::OPEN_STATUSES, true)) {
                 $tiers ??= RewardRuleService::tiers('backflow');
                 $labels ??= RewardRuleService::categories('backflow');
                 $user = RewardBasisService::user((string) $item->member_id);
-                $basis = $user ? RewardBasisService::backflowBasis($user, (string) $item->date) : [];
+                $basis = $user ? RewardBasisService::backflowBasis($user, (string) $item->date, $dailyFrom) : [];
                 $live = RewardBasisService::backflowReward($basis, $tiers, $labels);
+                $hasUnsettled = $live['has_unsettled'];
                 $item['amount'] = $live['amount'];
                 $item['loss_amount'] = $live['loss_amount'];
                 $item['need_flow'] = $live['need_flow'];
                 $item['details'] = $live['details'];
             }
-            foreach (self::grantFields($item->toArray()) as $field => $value) $item[$field] = $value;
+            foreach (self::grantFields($item->toArray(), $dailyFrom, $hasUnsettled) as $field => $value) $item[$field] = $value;
         }
 
 //        $count = $query->count();

+ 15 - 12
app/Services/RewardBaseSyncService.php

@@ -5,6 +5,7 @@ namespace App\Services;
 use App\Models\Rebate;
 use App\Models\Backflow;
 use App\Services\Sharing\EffectiveBet;
+use App\Support\BackflowPeriod;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
@@ -12,7 +13,7 @@ use InvalidArgumentException;
 use Throwable;
 
 /**
- * Keeps unpaid rebates (New York day) and backflow (Shanghai month) rows in step with share_bet_facts,
+ * Keeps unpaid rebates (New York day) and backflow (Beijing day or legacy month) in step with share_bet_facts,
  * so admin lists show every game category. Paid rows are never touched; payout recomputes on its own.
  */
 class RewardBaseSyncService
@@ -39,24 +40,26 @@ class RewardBaseSyncService
                 : now()->subDays(2);
             $query->where('updated_at', '>=', $since->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'));
         }
-        $days = $months = [];
-        $query->orderBy('id')->chunkById(500, function ($facts) use (&$days, &$months, $from, $to) {
+        $dailyFrom = BackflowPeriod::dailyFrom();
+        $days = $backflowPeriods = [];
+        $query->orderBy('id')->chunkById(500, function ($facts) use (&$days, &$backflowPeriods, $from, $to, $dailyFrom) {
             foreach ($facts as $fact) {
                 $day = Carbon::parse($fact->bet_at, 'Asia/Shanghai')->setTimezone('America/New_York')->toDateString();
                 if ($from === null || ($day >= $from && $day <= $to)) $days[$fact->user_id . '|' . $day] = [$fact->user_id, $day];
-                $months[$fact->user_id . '|' . substr((string) $fact->bet_date, 0, 7)] = [$fact->user_id, substr((string) $fact->bet_date, 0, 7)];
+                $period = BackflowPeriod::forDate((string) $fact->bet_date, $dailyFrom);
+                $backflowPeriods[$fact->user_id . '|' . $period] = [$fact->user_id, $period];
             }
         });
         $users = [];
         $backflowLabels = null;
-        foreach (['rebate' => $days, 'backflow' => $months] as $type => $keys) {
+        foreach (['rebate' => $days, 'backflow' => $backflowPeriods] as $type => $keys) {
             foreach ($keys as [$userId, $period]) {
                 try {
                     $user = $users[$userId] ??= DB::table('users')->where('id', $userId)->first(['id', 'member_id', 'level', 'username', 'first_name']);
                     if (!$user) continue;
                     if (\App\Support\MemberAudience::isSimulatedMember($user->member_id)) continue; // 模拟会员不产生返水、回水
                     if ($type === 'rebate' ? $this->rebate($user, $period)
-                        : $this->backflow($user, $period, $backflowLabels ??= RewardRuleService::categories('backflow'))) {
+                        : $this->backflow($user, $period, $dailyFrom, $backflowLabels ??= RewardRuleService::categories('backflow'))) {
                         ++$result[$type === 'rebate' ? 'rebates' : 'backflow'];
                     }
                 } catch (Throwable $e) {
@@ -94,18 +97,18 @@ class RewardBaseSyncService
         }, 3);
     }
 
-    private function backflow(object $user, string $month, array $labels): bool
+    private function backflow(object $user, string $period, ?string $dailyFrom, array $labels): bool
     {
-        return DB::transaction(function () use ($user, $month, $labels) {
-            $row = DB::table('backflow')->where('member_id', $user->member_id)->where('date', $month)->lockForUpdate()->first();
-            // 部分发放(status 2)的记录继续累计,之后可再发差额
+        return DB::transaction(function () use ($user, $period, $dailyFrom, $labels) {
+            $dateColumn = strlen($period) === 10 ? 'daily_date' : 'date';
+            $row = DB::table('backflow')->where('member_id', $user->member_id)->where($dateColumn, $period)->lockForUpdate()->first();
             if ($row && !in_array((int) $row->status, Backflow::OPEN_STATUSES, true)) return false;
-            $reward = RewardBasisService::backflowReward(RewardBasisService::backflowBasis($user, $month), labels: $labels);
+            $reward = RewardBasisService::backflowReward(RewardBasisService::backflowBasis($user, $period, $dailyFrom), labels: $labels);
             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)->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()]);
+            DB::table('backflow')->insert($values + ['member_id' => $user->member_id, 'date' => $period, 'status' => 0, 'created_at' => now()]);
             return true;
         }, 3);
     }

+ 16 - 13
app/Services/RewardBasisService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Models\LhcOrder;
 use App\Services\Sharing\EffectiveBet;
+use App\Support\BackflowPeriod;
 use Carbon\Carbon;
 use Illuminate\Database\Query\Builder;
 use Illuminate\Support\Collection;
@@ -11,7 +12,7 @@ use Illuminate\Support\Facades\DB;
 
 /**
  * 笔笔返/回水的计算口径:按分类汇总 share_bet_facts,再套用 RewardRuleService 的档位。
- * 笔笔返按纽约自然日,回水按北京时间自然月,与原有 rebates.date / backflow.date 一致。
+ * 笔笔返按纽约自然日,回水按北京时间自然日,切换前的注单仍归原月账。
  */
 class RewardBasisService
 {
@@ -33,10 +34,9 @@ class RewardBasisService
         return $basis;
     }
 
-    /** Category totals for one Asia/Shanghai month (Y-m). */
-    public static function backflowBasis(object $user, string $month): array
+    public static function backflowBasis(object $user, string $period, ?string $dailyFrom): array
     {
-        $start = Carbon::parse($month . '-01', 'Asia/Shanghai');
+        [$start, $end] = BackflowPeriod::bounds($period, $dailyFrom);
         // Historical facts identify a child bet. A missing or duplicated parent must not attribute its loss to a game.
         $orderType = DB::table('lhc_order_item')
             ->join('lhc_order', 'lhc_order.ordernum', '=', 'lhc_order_item.ordernum')
@@ -51,15 +51,17 @@ class RewardBasisService
 
         $facts = self::factsFor($user)
             ->where('category', '<>', 'third:3')
-            ->whereBetween('bet_date', [$start->toDateString(), $start->copy()->endOfMonth()->toDateString()])
-            ->select('category', 'bet_amount', 'effective_amount')
-            ->selectRaw('CASE WHEN category = ? AND settled = 0 THEN 0 ELSE net_profit END AS net_profit', ['self:lottery'])
+            ->where('bet_date', '>=', $start)->where('bet_date', '<', $end)
+            ->select('category', 'bet_amount', 'effective_amount', 'settled')
+            ->selectRaw('CASE WHEN settled = 0 THEN 0 ELSE net_profit END AS net_profit')
             ->selectRaw("CASE WHEN category = ? AND source = ? THEN ({$orderType->toSql()}) END AS lottery_order_type",
                 ['self:lottery', 'lhc_order_item']);
 
         $basis = [];
-        foreach (self::totals(DB::query()->fromSub($facts, 'monthly_facts'), ['category', 'lottery_order_type']) as $row) {
-            $amounts = self::amounts($row);
+        $totals = DB::query()->fromSub($facts, 'period_facts')
+            ->selectRaw('MAX(CASE WHEN settled = 0 THEN 1 ELSE 0 END) AS has_unsettled');
+        foreach (self::totals($totals, ['category', 'lottery_order_type']) as $row) {
+            $amounts = self::amounts($row) + ['has_unsettled' => (bool) $row->has_unsettled];
             $category = $row->category;
             if ($row->lottery_order_type !== null) {
                 $type = (int) $row->lottery_order_type;
@@ -102,8 +104,9 @@ class RewardBasisService
     public static function backflowReward(array $basis, ?array $tiers = null, ?array $labels = null): array
     {
         $tiers ??= RewardRuleService::tiers('backflow');
-        $result = self::empty($basis) + ['loss_amount' => '0.00'];
+        $result = self::empty($basis) + ['loss_amount' => '0.00', 'has_unsettled' => false];
         foreach ($basis as $category => $row) {
+            $result['has_unsettled'] = $result['has_unsettled'] || (!empty($tiers[$category]) && !empty($row['has_unsettled']));
             $loss = bccomp($row['profit'], '0', 2) < 0 ? bcsub('0', $row['profit'], 2) : '0.00';
             $labels ??= RewardRuleService::categories('backflow');
             $label = $category === 'self:lottery' ? '未识别彩种' : ($labels[$category] ?? $row['label'] ?? $category);
@@ -134,10 +137,10 @@ class RewardBasisService
         return self::rebateReward($basis, (int) ($user->level ?? 0));
     }
 
-    public static function backflow(object $reward): array
+    public static function backflow(object $reward, ?string $dailyFrom): array
     {
         $user = self::user((string) $reward->member_id);
-        return self::backflowReward($user ? self::backflowBasis($user, (string) $reward->date) : []);
+        return self::backflowReward($user ? self::backflowBasis($user, (string) $reward->date, $dailyFrom) : []);
     }
 
     private static function factsFor(object $user): Builder
@@ -148,7 +151,7 @@ class RewardBasisService
 
     private static function totals(Builder $query, array $groups): Collection
     {
-        $query->select($groups)
+        $query->addSelect($groups)
             ->selectRaw('SUM(bet_amount) AS bet_amount, SUM(effective_amount) AS effective_amount, SUM(net_profit) AS profit')
             ->groupBy($groups);
         foreach ($groups as $group) $query->orderBy($group);

+ 22 - 10
app/Services/RewardGrantService.php

@@ -7,6 +7,7 @@ use App\Models\Backflow;
 use App\Models\Rebate;
 use App\Models\Wallet;
 use App\Models\WalletBonus;
+use App\Support\BackflowPeriod;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use RuntimeException;
@@ -29,15 +30,23 @@ class RewardGrantService
                 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 (($scheduledCutoff !== null && (string) $reward->date >= $cutoff)
-                || ($type === 'rebate' && (string) $reward->date > $cutoff)) {
-                throw new RuntimeException('未到发放时间', HttpStatus::CUSTOM_ERROR);
+            $period = (string) $reward->date;
+            $dailyFrom = null;
+            if ($type === 'rebate') {
+                $today = Carbon::now('America/New_York')->toDateString();
+                $closed = $period < $today;
+                if ($period > $today || ($scheduledCutoff !== null && !$closed)) {
+                    throw new RuntimeException('未到发放时间', HttpStatus::CUSTOM_ERROR);
+                }
+                if ($scheduledCutoff !== null && $period >= $scheduledCutoff) return false;
+            } else {
+                $today = Carbon::now(BackflowPeriod::TIMEZONE)->toDateString();
+                $dailyFrom = BackflowPeriod::dailyFrom();
+                [$start, $end] = BackflowPeriod::bounds($period, $dailyFrom);
+                $closed = $today >= $end;
+                if ($start > $today) throw new RuntimeException('未到发放时间', HttpStatus::CUSTOM_ERROR);
+                if ($scheduledCutoff !== null && (!$closed || $end > $scheduledCutoff)) return false;
             }
-            if ($scheduledCutoff !== null && (string) $reward->date >= $scheduledCutoff) return false;
 
             $backflowDetails = '';
             if ($type === 'rebate') {
@@ -54,7 +63,11 @@ class RewardGrantService
                     $remark .= ";{$detail['label']}:{$detail['effective_amount']}×{$detail['percentage']}%";
                 }
             } else {
-                $result = RewardBasisService::backflow($reward);
+                $result = RewardBasisService::backflow($reward, $dailyFrom);
+                if ($closed && $result['has_unsettled'] && BackflowPeriod::waitsForSettlement($period, $dailyFrom)) {
+                    if ($scheduledCutoff !== null) return false;
+                    throw new RuntimeException('该期仍有未结算注单,请待结算后发放', HttpStatus::CUSTOM_ERROR);
+                }
                 $reward->loss_amount = $result['loss_amount'];
                 $reward->backflow_ratio = '0';
                 $changeType = '回水';
@@ -72,7 +85,6 @@ class RewardGrantService
             // 每次只发新增差额,金额回落不追回;未实际入账的打码量也不能记为已发放。
             $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);
             }

+ 39 - 0
app/Support/BackflowPeriod.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Support;
+
+use Carbon\CarbonImmutable;
+use Illuminate\Support\Facades\DB;
+
+final class BackflowPeriod
+{
+    public const TIMEZONE = 'Asia/Shanghai';
+    public const DAILY_FROM_KEY = 'backflow_daily_from';
+
+    public static function dailyFrom(): ?string
+    {
+        return DB::connection()->getSchemaBuilder()->hasTable('share_system_state')
+            ? DB::table('share_system_state')->where('key', self::DAILY_FROM_KEY)->value('value') : null;
+    }
+
+    public static function forDate(string $date, ?string $dailyFrom): string
+    {
+        return $dailyFrom !== null && $date >= $dailyFrom ? $date : substr($date, 0, 7);
+    }
+
+    /** Inclusive start and exclusive end, both in Beijing. */
+    public static function bounds(string $period, ?string $dailyFrom): array
+    {
+        if (strlen($period) === 10) {
+            return [$period, CarbonImmutable::parse($period, self::TIMEZONE)->addDay()->toDateString()];
+        }
+        $start = $period . '-01';
+        $end = CarbonImmutable::parse($start, self::TIMEZONE)->addMonth()->toDateString();
+        return [$start, $dailyFrom === null ? $end : max($start, min($end, $dailyFrom))];
+    }
+
+    public static function waitsForSettlement(string $period, ?string $dailyFrom): bool
+    {
+        return strlen($period) === 10 || ($dailyFrom !== null && $period === substr($dailyFrom, 0, 7));
+    }
+}

+ 49 - 0
database/migrations/2026_11_11_190000_start_daily_backflow.php

@@ -0,0 +1,49 @@
+<?php
+
+use App\Support\BackflowPeriod;
+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
+    {
+        if (!Schema::hasColumn('backflow', 'daily_date')) {
+            Schema::table('backflow', function (Blueprint $table) {
+                // Keep legacy monthly rows and enforce one row per member for each new daily period.
+                $table->string('daily_date', 10)->nullable()->virtualAs('CASE WHEN LENGTH(date) = 10 THEN date END');
+            });
+        }
+        if (!Schema::hasIndex('backflow', 'backflow_member_daily_unique')) {
+            Schema::table('backflow', function (Blueprint $table) {
+                $table->unique(['member_id', 'daily_date'], 'backflow_member_daily_unique');
+            });
+        }
+        if (!Schema::hasIndex('backflow', 'backflow_status_date_index')) {
+            Schema::table('backflow', function (Blueprint $table) {
+                $table->index(['status', 'date'], 'backflow_status_date_index');
+            });
+        }
+        $now = now(BackflowPeriod::TIMEZONE);
+        DB::table('share_system_state')->insertOrIgnore([
+            'key' => BackflowPeriod::DAILY_FROM_KEY,
+            'value' => $now->copy()->addDay()->toDateString(),
+            'created_at' => $now,
+            'updated_at' => $now,
+        ]);
+    }
+
+    public function down(): void
+    {
+        if (DB::table('backflow')->whereNotNull('daily_date')->exists()) {
+            throw new RuntimeException('已有日回水记录,不能自动恢复月回水');
+        }
+        DB::table('share_system_state')->where('key', BackflowPeriod::DAILY_FROM_KEY)->delete();
+        Schema::table('backflow', function (Blueprint $table) {
+            $table->dropIndex('backflow_status_date_index');
+            $table->dropUnique('backflow_member_daily_unique');
+            $table->dropColumn('daily_date');
+        });
+    }
+};

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

@@ -6,7 +6,7 @@
 
 ## 背景
 
-回水可以在**当月**手动提前发放。发放后会员继续投注,当月亏损还会增加、回水金额会变大,此时可以**继续发放差额**。列表新增字段,用来显示已经发了多少、还有多少没发。
+回水按北京时间自然日结算。当天可以手动提前发放,之后可继续发放新增差额。历史月账继续显示。
 
 ## 一、列表 `GET /admin/backflow`
 
@@ -15,14 +15,15 @@
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
 | `status` | int | 否 | `0` 未发放,`1` 已发放,`2` 部分发放;不传为全部 |
+| `date` | string | 否 | `YYYY-MM-DD` 查询当天记录,`YYYY-MM` 查询整月(包括历史月账) |
 
-其他参数不变(`page`、`limit`、`member_id`、`account`、`first_name`、`date`)。
+其他筛选参数为 `page`、`limit`、`member_id`、`account`、`first_name`。
 
 ### 新增/变更返回字段(`data.data[]` 每行)
 
 | 字段 | 类型 | 说明 |
 |---|---|---|
-| `amount` | string | **应发总额**:按当前亏损与规则算出的本月回水,两位小数。未结清(`status` 0/2)的记录在每次查询列表时按最新注单和**当前规则**实时重算,与点“发放”时一致;`loss_amount`、`need_flow`、`details` 同样为实时值 |
+| `amount` | string | 本期应发总额,两位小数。未结清时按最新注单和当前规则实时计算,`loss_amount`、`need_flow`、`details` 同步更新 |
 | `granted_amount` | string | **已发放**:累计已打入会员钱包的金额 |
 | `pending_amount` | string | **待发放**:`amount − granted_amount`,最小 `0.00`;已发放(`status=1`)的记录恒为 `0.00` |
 | `status` | int | `0` 未发放/`1` 已发放/`2` 部分发放(新增) |
@@ -33,7 +34,7 @@
 ```json
 {
   "id": 12,
-  "date": "2026-11",
+  "date": "2026-11-12",
   "member_id": "1001",
   "account": "alice",
   "amount": "50.00",
@@ -42,10 +43,10 @@
   "status": 2,
   "status_text": "部分发放",
   "can_grant": true,
-  "grant_time": "2026-11-05 16:20",
+  "grant_time": "2026-11-12 16:20",
   "loss_amount": "350.00",
   "need_flow": "50.00",
-  "details": [ ... ]
+  "details": []
 }
 ```
 
@@ -54,18 +55,18 @@
 | status | 含义 | 何时出现 |
 |---|---|---|
 | 0 未发放 | 还没发过 | 新记录 |
-| 2 部分发放 | 当月已提前发过,月份未结束,仍在累计 | 当月记录手动发放后 |
-| 1 已发放 | 月份已结束且已结清,不再变化 | 往月记录发放后(手动或自动) |
+| 2 部分发放 | 本期已提前发过,仍在累计 | 当天记录手动发放后 |
+| 1 已发放 | 周期已结束且已结清 | 已结束周期发放后 |
 
 ### `can_grant` 规则
 
 - `status=1`:`false`。
-- 当月记录(`status` 为 0 或 2):`pending_amount > 0` 时为 `true`;没有差额时为 `false`。
-- 往月记录(`status` 为 0 或 2):`true`。即使差额为 0,点“发放”也会把记录结清为“已发放”。
+- 周期内的未结清记录在 `pending_amount > 0` 时可发放。
+- 已结束周期的未结清记录可发放或结清。仍有会影响回水金额的未结算注单时,日账及切换当月的月账须等待注单结算。
 
 ### 建议展示
 
-列:月份 | 会员 | 亏损 | 应发总额 `amount` | 已发放 `granted_amount` | 待发放 `pending_amount` | 状态 `status_text` | 最近发放时间 | 操作。
+列:结算日期 | 会员 | 亏损 | 应发总额 `amount` | 已发放 `granted_amount` | 待发放 `pending_amount` | 状态 `status_text` | 最近发放时间 | 操作。
 
 操作列:`can_grant=true` 显示“发放”,否则隐藏或置灰。当 `status=2` 时按钮文字建议显示“继续发放”。
 
@@ -78,15 +79,16 @@
 1. 发放时先按最新注单和规则重算 `amount`。
 2. 本次发放金额 = `amount − granted_amount`(最小为 0)。
 3. 打码量同理只加差额部分。
-4. 当月记录发放后变为 `status=2`,往月记录发放后变为 `status=1`。
+4. 提前发放后为 `status=2`,周期结束后结清为 `status=1`。
 
-例:当月亏 50,档位“亏 10 奖 8”,点发放 → 发 8,`已发放 8`。之后亏到 350,命中“亏 300 奖 50” → 列表显示 `应发 50 / 已发 8 / 待发 42`,再点发放 → 发 42,`已发放 50`。
+当天净亏 50 时命中「亏 10 奖 8」,提前发放 8。当天净亏增加到 350 后命中「亏 300 奖 50」,再次发放补差 42。
 
 ### 错误提示(`code≠0`,直接显示 `msg`)
 
 | msg | 情况 |
 |---|---|
-| `暂无可发放金额(按当前规则应发 x,已发放 y)` | 当月记录没有差额(`can_grant=false` 时不应能点到)。若列表刚显示有待发放,刷新列表即可看到最新金额 |
+| `暂无可发放金额(按当前规则应发 x,已发放 y)` | 周期内的记录已无待发差额,刷新列表可看到最新金额 |
+| `该期仍有未结算注单,请待结算后发放` | 已结束周期仍有相关注单待结算 |
 | `奖励已发放,无需再次发放` | `status=1` 的记录 |
 | `数据不存在` | id 错误 |
 
@@ -94,8 +96,8 @@
 
 - 会员钱包每次发放都有一条“回水”资金记录,金额为本次发放的差额;备注末尾带 `本次发放: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`。前台列表仍只显示往月记录。
+会员回水列表 `/api/backflow/lists` 返回前一天及更早的日账、往月月账和已结清的本月月账。日期筛选支持 `YYYY-MM-DD` 与 `YYYY-MM`。

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

@@ -6,7 +6,7 @@
 
 ## 背景
 
-回水可以在**当月**手动提前发放。发放后会员继续投注,当月亏损还会增加、回水金额会变大,此时可以**继续发放差额**。列表新增字段,用来显示已经发了多少、还有多少没发。
+回水按北京时间自然日结算。当天可以手动提前发放,之后可继续发放新增差额。历史月账继续显示。
 
 ## 一、列表 `GET /admin/backflow`
 
@@ -15,14 +15,15 @@
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
 | `status` | int | 否 | `0` 未发放,`1` 已发放,`2` 部分发放;不传为全部 |
+| `date` | string | 否 | `YYYY-MM-DD` 查询当天记录,`YYYY-MM` 查询整月(包括历史月账) |
 
-其他参数不变(`page`、`limit`、`member_id`、`account`、`first_name`、`date`)。
+其他筛选参数为 `page`、`limit`、`member_id`、`account`、`first_name`。
 
 ### 新增/变更返回字段(`data.data[]` 每行)
 
 | 字段 | 类型 | 说明 |
 |---|---|---|
-| `amount` | string | **应发总额**:按当前亏损与规则算出的本月回水,两位小数。未结清(`status` 0/2)的记录在每次查询列表时按最新注单和**当前规则**实时重算,与点“发放”时一致;`loss_amount`、`need_flow`、`details` 同样为实时值 |
+| `amount` | string | 本期应发总额,两位小数。未结清时按最新注单和当前规则实时计算,`loss_amount`、`need_flow`、`details` 同步更新 |
 | `granted_amount` | string | **已发放**:累计已打入会员钱包的金额 |
 | `pending_amount` | string | **待发放**:`amount − granted_amount`,最小 `0.00`;已发放(`status=1`)的记录恒为 `0.00` |
 | `status` | int | `0` 未发放/`1` 已发放/`2` 部分发放(新增) |
@@ -33,7 +34,7 @@
 ```json
 {
   "id": 12,
-  "date": "2026-11",
+  "date": "2026-11-12",
   "member_id": "1001",
   "account": "alice",
   "amount": "50.00",
@@ -42,10 +43,10 @@
   "status": 2,
   "status_text": "部分发放",
   "can_grant": true,
-  "grant_time": "2026-11-05 16:20",
+  "grant_time": "2026-11-12 16:20",
   "loss_amount": "350.00",
   "need_flow": "50.00",
-  "details": [ ... ]
+  "details": []
 }
 ```
 
@@ -54,18 +55,18 @@
 | status | 含义 | 何时出现 |
 |---|---|---|
 | 0 未发放 | 还没发过 | 新记录 |
-| 2 部分发放 | 当月已提前发过,月份未结束,仍在累计 | 当月记录手动发放后 |
-| 1 已发放 | 月份已结束且已结清,不再变化 | 往月记录发放后(手动或自动) |
+| 2 部分发放 | 本期已提前发过,仍在累计 | 当天记录手动发放后 |
+| 1 已发放 | 周期已结束且已结清 | 已结束周期发放后 |
 
 ### `can_grant` 规则
 
 - `status=1`:`false`。
-- 当月记录(`status` 为 0 或 2):`pending_amount > 0` 时为 `true`;没有差额时为 `false`。
-- 往月记录(`status` 为 0 或 2):`true`。即使差额为 0,点“发放”也会把记录结清为“已发放”。
+- 周期内的未结清记录在 `pending_amount > 0` 时可发放。
+- 已结束周期的未结清记录可发放或结清。仍有会影响回水金额的未结算注单时,日账及切换当月的月账须等待注单结算。
 
 ### 建议展示
 
-列:月份 | 会员 | 亏损 | 应发总额 `amount` | 已发放 `granted_amount` | 待发放 `pending_amount` | 状态 `status_text` | 最近发放时间 | 操作。
+列:结算日期 | 会员 | 亏损 | 应发总额 `amount` | 已发放 `granted_amount` | 待发放 `pending_amount` | 状态 `status_text` | 最近发放时间 | 操作。
 
 操作列:`can_grant=true` 显示“发放”,否则隐藏或置灰。当 `status=2` 时按钮文字建议显示“继续发放”。
 
@@ -78,15 +79,16 @@
 1. 发放时先按最新注单和规则重算 `amount`。
 2. 本次发放金额 = `amount − granted_amount`(最小为 0)。
 3. 打码量同理只加差额部分。
-4. 当月记录发放后变为 `status=2`,往月记录发放后变为 `status=1`。
+4. 提前发放后为 `status=2`,周期结束后结清为 `status=1`。
 
-例:当月亏 50,档位“亏 10 奖 8”,点发放 → 发 8,`已发放 8`。之后亏到 350,命中“亏 300 奖 50” → 列表显示 `应发 50 / 已发 8 / 待发 42`,再点发放 → 发 42,`已发放 50`。
+当天净亏 50 时命中「亏 10 奖 8」,提前发放 8。当天净亏增加到 350 后命中「亏 300 奖 50」,再次发放补差 42。
 
 ### 错误提示(`code≠0`,直接显示 `msg`)
 
 | msg | 情况 |
 |---|---|
-| `暂无可发放金额(按当前规则应发 x,已发放 y)` | 当月记录没有差额(`can_grant=false` 时不应能点到)。若列表刚显示有待发放,刷新列表即可看到最新金额 |
+| `暂无可发放金额(按当前规则应发 x,已发放 y)` | 周期内的记录已无待发差额,刷新列表可看到最新金额 |
+| `该期仍有未结算注单,请待结算后发放` | 已结束周期仍有相关注单待结算 |
 | `奖励已发放,无需再次发放` | `status=1` 的记录 |
 | `数据不存在` | id 错误 |
 
@@ -94,8 +96,8 @@
 
 - 会员钱包每次发放都有一条“回水”资金记录,金额为本次发放的差额;备注末尾带 `本次发放: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`。前台列表仍只显示往月记录。
+会员回水列表 `/api/backflow/lists` 返回前一天及更早的日账、往月月账和已结清的本月月账。日期筛选支持 `YYYY-MM-DD` 与 `YYYY-MM`。

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

@@ -104,12 +104,12 @@ POST /admin/backflow/grant-settings
 
 - 服务器每分钟检查一次设置,自动开关开启且到达每日设定时间后开始处理,调度精度为分钟。
 - `rebate` 只发放在**本次设定时间对应的纽约日期**之前、且 `status` 为 `0` 或 `2` 的日记录,沿用后台笔笔返的日口径。
-- `backflow` 只发放在**本次设定时间对应的上海月份**之前、且 `status` 为 `0` 或 `2` 的月记录,沿用后台回水的月口径。即使每天检查,当月记录也要等下个月才具备发放资格。
+- `backflow` 以北京时间自然日结算。到达后台设定的发放时间后,处理前一日及更早的未结清记录。仍有会影响回水的未结算注单时,待注单结算后重试。切换前的月账只统计切换日之前的注单。
 - 开启自动后,符合条件的**历史未发放记录也会处理**。已经发放的记录不会再次入账。
 - 当天执行时间已过时,开启开关或重启任务后,下一轮检查会补处理当天应发的记录。当天时间未到则等待设定时间。
 - 单条发放失败不影响其他记录,失败记录保留未发放状态,在后续检查中重试;成功记录不会重复发放。
 - 关闭开关或将时间改到当天尚未到达的时间后,后续自动发放停止;已在提交中的单条发放会完成,不撤回已入账奖励。
-- 回水手动发放不限月份(2026-11-05 起),当月记录可提前发,之后继续累计并可再发差额(状态“部分发放”);笔笔返手动发放可提前发放纽约当天记录,之后继续累计并仅补发新增差额;未来日期不可发放。
+- 回水可提前手动发放北京时间当天的差额,笔笔返可提前手动发放纽约当天的差额。提前发放后记录继续累计,可以补发新增差额。未来日期不可发放。
 - 自动模式下仍可使用原有手动发放按钮。手动与自动共用发放事务、奖励记录锁和钱包锁,重复处理同一记录不会重复入账。
 
 时间口径示例:笔笔返设为北京时间每天 `03:00`。`2026-11-01 03:00` 对应纽约 `2026-10-31`,本轮只处理 `2026-10-30` 及以前的未发放日记录;`2026-10-31` 的日记录留到后续符合条件的计划轮次。当天下午补跑仍按当天 `03:00` 的截止范围处理。

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

@@ -77,7 +77,7 @@ Content-Type: application/json
 
 ## 计算与列表
 
-以北京自然月为周期。F1 彩票按彩种独立计算净亏,每个彩种命中达到的最高档。未配置、净赢或未达到最低档时,奖励为 0。需要打码量为奖励乘以该档倍数。各分类和彩种的奖励及打码量分别汇总。
+以北京时间自然日为周期。F1 彩票按彩种独立计算净亏,每个彩种命中达到的最高档。未配置、净赢或未达到最低档时,奖励为 0。需要打码量为奖励乘以该档倍数。各分类和彩种的奖励及打码量分别汇总。切换前的注单归原月账,切换日起的注单归对应日账。
 
 `GET /admin/backflow` 每行包含以下字段。
 
@@ -86,7 +86,7 @@ Content-Type: application/json
 | `loss_amount` | 各分类和彩种的净亏合计 |
 | `details` | 分类和彩种明细数组,每项见下表 |
 | `need_flow` | 需要打码量合计 |
-| `can_grant` | 是否可点「发放」。当月有待发差额才可发放,往月未结清可发放或结清 |
+| `can_grant` | 是否可点「发放」。周期内有待发差额才可发放,已结束周期可在相关注单结算后发放或结清 |
 
 `details` 每项的字段如下。
 
@@ -95,7 +95,7 @@ Content-Type: application/json
 | `category` / `label` | 游戏分类或彩种的标识和显示名称 |
 | `bet_amount` | 该分类投注额 |
 | `effective_amount` | 该分类有效投注 |
-| `profit` | 该分类当月输赢 |
+| `profit` | 该分类在记录周期内的输赢 |
 | `loss_amount` | 该分类亏损(净赢时为 0) |
 | `rule_id` | 命中的规则 id,未命中为 `null` |
 | `min_loss_amount` | 命中档位的亏损门槛(未命中时没有该字段) |
@@ -104,5 +104,5 @@ Content-Type: application/json
 | `need_flow` | 该分类需要打码量 |
 
 - `recharge_amount`、`withdrawal_amount` 仅供展示。
-- 当月及往月未结清记录的 `amount` 按当前规则预估,发放时重新计算。当月可手动发放差额,自动发放处理往月记录。
+- 未结清记录的 `amount` 按当前规则预估,发放时重新计算。当天可手动发放差额。开启自动发放后,系统按后台设定时间处理前一天及更早的未结清记录。
 - 已结清记录显示发放时保存的明细,旧记录可能包含 `self:lottery`。未结清记录里无法确认彩种的历史注单以 `category: self:lottery`、`label: 未识别彩种` 展示,奖励为 0。

+ 52 - 0
tests/Integration/AutomaticRewardGrantTest.php

@@ -16,6 +16,7 @@ use App\Services\RewardBaseSyncService;
 use App\Services\RewardGrantService;
 use App\Services\RewardGrantSettingsService;
 use App\Services\SiteInboxService;
+use App\Support\BackflowPeriod;
 use Carbon\Carbon;
 use Illuminate\Config\Repository;
 use Illuminate\Database\Capsule\Manager;
@@ -833,6 +834,57 @@ class AutomaticRewardGrantTest extends TestCase
         RewardGrantService::grant('rebate', $rebate, 'operator-one');
     }
 
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_daily_backflow_uses_the_configured_time_and_keeps_the_cutover_month_separate(): void
+    {
+        $this->enable('backflow', '00:15');
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_190000_start_daily_backflow.php';
+        $migration->up();
+        $this->assertSame('2026-10-26', BackflowPeriod::dailyFrom());
+
+        $this->lotteryFact('1001', 5, '2026-10-25 23:59:59', '120', '-120');
+        $this->lotteryFact('1001', 5, '2026-10-26 00:00:00', '100', '-100');
+        $unsettledAt = '2026-10-26 23:58:00';
+        $this->lotteryFact('1001', 5, $unsettledAt, '0', '0', ['bet_amount' => '30', 'settled' => 0]);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 00:14:59', 'Asia/Shanghai'));
+        $migration->up();
+        $this->assertSame('2026-10-26', BackflowPeriod::dailyFrom(), 'Retry keeps the original cutoff');
+        $this->assertSame('00:15', RewardGrantSettingsService::get('backflow')['grant_time']);
+        $this->assertSame('2026-10-26', BackflowService::updateOrCreate('1001', 25)->date);
+        $this->assertSame(2, (new RewardBaseSyncService())->run()['backflow']);
+        $this->assertSame(['2026-10', '2026-10-26'], DB::table('backflow')->orderBy('date')->pluck('date')->all());
+        $this->assertEquals(25, DB::table('backflow')->where('date', '2026-10-26')->value('recharge_amount'));
+        $this->assertSame(0, $this->runRewards()['backflow']['granted']);
+
+        Carbon::setTestNow(Carbon::parse('2026-10-26 00:15:00', 'Asia/Shanghai'));
+        $this->assertSame(1, $this->runRewards()['backflow']['granted']);
+        $this->assertEquals(120, DB::table('backflow')->where('date', '2026-10')->value('loss_amount'));
+        $this->assertSame(0, (int) DB::table('backflow')->where('date', '2026-10-26')->value('status'));
+
+        Carbon::setTestNow(Carbon::parse('2026-10-27 00:15:00', 'Asia/Shanghai'));
+        $this->lotteryFact('1001', 5, '2026-10-27 00:00:00', '500', '500');
+        (new RewardBaseSyncService())->run();
+        $this->assertSame(1, $this->runRewards()['backflow']['skipped']);
+        $this->assertEquals(55, DB::table('wallets')->value('available_balance'));
+        $this->request('/admin/backflow', ['page' => 1, 'limit' => 10, 'date' => '2026-10-26']);
+        PaginationState::resolveUsing($this->app);
+        $list = (new BackflowController())->index()->getData(true);
+        $this->assertSame(0, $list['code'], $list['msg']);
+        $this->assertSame(1, $list['data']['total']);
+        $this->assertFalse($list['data']['data'][0]['can_grant']);
+
+        DB::table('share_bet_facts')->where('bet_at', $unsettledAt)
+            ->update(['settled' => 1, 'effective_amount' => '30', 'net_profit' => '-30', 'updated_at' => now()]);
+        (new RewardBaseSyncService())->run();
+        $this->assertSame(1, $this->runRewards()['backflow']['granted']);
+        $this->assertSame(0, $this->runRewards()['backflow']['granted']);
+        $this->assertEquals(130, DB::table('backflow')->where('date', '2026-10-26')->value('loss_amount'));
+        $this->assertEquals(90, DB::table('wallets')->value('available_balance'));
+        $this->assertSame(0, (int) DB::table('backflow')->where('date', '2026-10-27')->value('status'));
+        $this->assertSame(2, DB::table('balance_logs')->where('change_type', '回水')->count());
+    }
+
     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]);