RewardBasisService.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. <?php
  2. namespace App\Services;
  3. use App\Models\LhcOrder;
  4. use App\Services\Sharing\EffectiveBet;
  5. use App\Support\BackflowPeriod;
  6. use Carbon\Carbon;
  7. use Illuminate\Database\Query\Builder;
  8. use Illuminate\Support\Collection;
  9. use Illuminate\Support\Facades\DB;
  10. /**
  11. * 笔笔返/回水的计算口径:按分类汇总 share_bet_facts,再套用 RewardRuleService 的档位。
  12. * 笔笔返按纽约自然日,回水按北京时间自然日,切换前的注单仍归原月账。
  13. */
  14. class RewardBasisService
  15. {
  16. public static function user(string $memberId): ?object
  17. {
  18. return DB::table('users')->where('member_id', $memberId)->first(['id', 'member_id', 'level', 'username', 'first_name']);
  19. }
  20. /** Category totals for one New York day: bet (non-void), effective and profit (settled only). */
  21. public static function rebateBasis(object $user, string $nyDate): array
  22. {
  23. $start = Carbon::parse($nyDate . ' 00:00:00', 'America/New_York')->setTimezone('Asia/Shanghai');
  24. $end = $start->copy()->addDay();
  25. $query = self::factsFor($user)
  26. ->whereBetween('bet_date', [$start->toDateString(), $end->toDateString()])
  27. ->where('bet_at', '>=', $start->format('Y-m-d H:i:s'))->where('bet_at', '<', $end->format('Y-m-d H:i:s'));
  28. $basis = [];
  29. foreach (self::totals($query, ['category']) as $row) $basis[$row->category] = self::amounts($row);
  30. return $basis;
  31. }
  32. public static function backflowBasis(object $user, string $period, ?string $dailyFrom): array
  33. {
  34. [$start, $end] = BackflowPeriod::bounds($period, $dailyFrom);
  35. // Historical facts identify a child bet. A missing or duplicated parent must not attribute its loss to a game.
  36. $orderType = DB::table('lhc_order_item')
  37. ->join('lhc_order', 'lhc_order.ordernum', '=', 'lhc_order_item.ordernum')
  38. ->whereColumn('lhc_order_item.id', 'share_bet_facts.source_id')
  39. ->whereColumn('lhc_order_item.member_id', 'share_bet_facts.member_id');
  40. $grammar = $orderType->getGrammar();
  41. $type = $grammar->wrap('lhc_order.type');
  42. $orderMember = $grammar->wrap('lhc_order.member_id');
  43. $itemMember = $grammar->wrap('lhc_order_item.member_id');
  44. $orderType->selectRaw("CASE WHEN COUNT(*) = 1 AND MIN($orderMember) = MIN($itemMember)
  45. AND MIN($type) BETWEEN 1 AND 13 THEN MIN($type) END");
  46. $facts = self::factsFor($user)
  47. ->where('category', '<>', 'third:3')
  48. ->where('bet_date', '>=', $start)->where('bet_date', '<', $end)
  49. ->select('category', 'bet_amount', 'effective_amount', 'settled')
  50. ->selectRaw('CASE WHEN settled = 0 THEN 0 ELSE net_profit END AS net_profit')
  51. ->selectRaw("CASE WHEN category = ? AND source = ? THEN ({$orderType->toSql()}) END AS lottery_order_type",
  52. ['self:lottery', 'lhc_order_item']);
  53. $basis = [];
  54. $totals = DB::query()->fromSub($facts, 'period_facts')
  55. ->selectRaw('MAX(CASE WHEN settled = 0 THEN 1 ELSE 0 END) AS has_unsettled');
  56. foreach (self::totals($totals, ['category', 'lottery_order_type']) as $row) {
  57. $amounts = self::amounts($row) + ['has_unsettled' => (bool) $row->has_unsettled];
  58. $category = $row->category;
  59. if ($row->lottery_order_type !== null) {
  60. $type = (int) $row->lottery_order_type;
  61. // lhc_order uses 1–4 for Mark Six, 5–6 for the two 28 games, and 7–13 for jisu types 1–7.
  62. $category = match (true) {
  63. $type <= 4 => 'self:lottery:lhc:' . $type,
  64. $type === 5 => 'self:lottery:pc28',
  65. $type === 6 => 'self:lottery:jisu28',
  66. default => 'self:lottery:jisu:' . ($type - 6),
  67. };
  68. $amounts['label'] = LhcOrder::getRemark($type);
  69. }
  70. $basis[$category] = $amounts;
  71. }
  72. return $basis;
  73. }
  74. /** Highest tier of the member's level whose threshold the category's effective bet reaches. */
  75. public static function rebateReward(array $basis, int $level, ?array $tiers = null): array
  76. {
  77. $tiers ??= RewardRuleService::tiers('rebate');
  78. $result = self::empty($basis);
  79. foreach ($basis as $category => $row) {
  80. $detail = self::detail($category, $row) + ['level' => $level];
  81. if (bccomp($row['effective_amount'], '0', 2) > 0) {
  82. foreach ($tiers[$category] ?? [] as $tier) {
  83. if ($tier['level'] !== $level || bccomp($row['effective_amount'], $tier['min_effective_amount'], 2) < 0) continue;
  84. $amount = bcmul($row['effective_amount'], bcdiv($tier['percentage'], '100', 8), 2);
  85. $detail = self::matched($detail, $tier, $amount) + ['min_effective_amount' => $tier['min_effective_amount'],
  86. 'percentage' => $tier['percentage']];
  87. break;
  88. }
  89. }
  90. $result = self::add($result, $detail);
  91. }
  92. return $result;
  93. }
  94. /** Highest tier whose loss threshold the category's monthly net loss reaches; the reward is a fixed amount. */
  95. public static function backflowReward(array $basis, ?array $tiers = null, ?array $labels = null): array
  96. {
  97. $tiers ??= RewardRuleService::tiers('backflow');
  98. $result = self::empty($basis) + ['loss_amount' => '0.00', 'has_unsettled' => false];
  99. foreach ($basis as $category => $row) {
  100. $result['has_unsettled'] = $result['has_unsettled'] || (!empty($tiers[$category]) && !empty($row['has_unsettled']));
  101. $loss = bccomp($row['profit'], '0', 2) < 0 ? bcsub('0', $row['profit'], 2) : '0.00';
  102. $labels ??= RewardRuleService::categories('backflow');
  103. $label = $category === 'self:lottery' ? '未识别彩种' : ($labels[$category] ?? $row['label'] ?? $category);
  104. $detail = self::detail($category, $row, $label) + ['loss_amount' => $loss];
  105. if (bccomp($loss, '0', 2) > 0) {
  106. $result['loss_amount'] = bcadd($result['loss_amount'], $loss, 2);
  107. $categoryTiers = $category === 'self:lottery' ? [] : ($tiers[$category] ?? []);
  108. foreach ($categoryTiers as $tier) {
  109. if (bccomp($loss, $tier['min_loss_amount'], 2) < 0) continue;
  110. $detail = self::matched($detail, $tier, $tier['reward_amount']) + ['min_loss_amount' => $tier['min_loss_amount']];
  111. break;
  112. }
  113. }
  114. $result = self::add($result, $detail);
  115. }
  116. return $result;
  117. }
  118. /** Fresh rebate result for a stored row; legacy PC28 days without any facts keep their stored effective bet. */
  119. public static function rebate(object $reward): array
  120. {
  121. $user = self::user((string) $reward->member_id);
  122. $basis = $user ? self::rebateBasis($user, (string) $reward->date) : [];
  123. if ($basis === [] && bccomp((string) $reward->effective_betting_amount, '0', 2) > 0) {
  124. $basis = ['self:lottery' => ['bet_amount' => self::money($reward->betting_amount),
  125. 'effective_amount' => self::money($reward->effective_betting_amount), 'profit' => self::money($reward->profit ?? 0)]];
  126. }
  127. return self::rebateReward($basis, (int) ($user->level ?? 0));
  128. }
  129. public static function backflow(object $reward, ?string $dailyFrom): array
  130. {
  131. $user = self::user((string) $reward->member_id);
  132. return self::backflowReward($user ? self::backflowBasis($user, (string) $reward->date, $dailyFrom) : []);
  133. }
  134. private static function factsFor(object $user): Builder
  135. {
  136. $currency = strtoupper(trim((string) config('third_game.currency', 'CNY')));
  137. return DB::table('share_bet_facts')->where('user_id', $user->id)->where('voided', 0)->where('currency', $currency);
  138. }
  139. private static function totals(Builder $query, array $groups): Collection
  140. {
  141. $query->addSelect($groups)
  142. ->selectRaw('SUM(bet_amount) AS bet_amount, SUM(effective_amount) AS effective_amount, SUM(net_profit) AS profit')
  143. ->groupBy($groups);
  144. foreach ($groups as $group) $query->orderBy($group);
  145. return $query->get();
  146. }
  147. private static function amounts(object $row): array
  148. {
  149. return ['bet_amount' => self::money($row->bet_amount), 'effective_amount' => self::money($row->effective_amount),
  150. 'profit' => self::money($row->profit)];
  151. }
  152. private static function empty(array $basis): array
  153. {
  154. return ['betting_amount' => '0.00', 'effective_amount' => '0.00', 'profit' => '0.00',
  155. 'amount' => '0.00', 'need_flow' => '0.00', 'details' => [], 'has_facts' => $basis !== []];
  156. }
  157. private static function detail(string $category, array $row, ?string $label = null): array
  158. {
  159. return ['category' => $category, 'label' => $label ?? RewardRuleService::categories()[$category] ?? $category,
  160. 'bet_amount' => $row['bet_amount'], 'effective_amount' => $row['effective_amount'], 'profit' => $row['profit'],
  161. 'rule_id' => null, 'turnover_multiple' => '0.00', 'amount' => '0.00', 'need_flow' => '0.00'];
  162. }
  163. private static function matched(array $detail, array $tier, string $amount): array
  164. {
  165. return array_merge($detail, ['rule_id' => $tier['id'], 'turnover_multiple' => $tier['turnover_multiple'],
  166. 'amount' => $amount, 'need_flow' => bcmul($amount, $tier['turnover_multiple'], 2)]);
  167. }
  168. private static function add(array $result, array $detail): array
  169. {
  170. $result['betting_amount'] = bcadd($result['betting_amount'], $detail['bet_amount'], 2);
  171. $result['effective_amount'] = bcadd($result['effective_amount'], $detail['effective_amount'], 2);
  172. $result['profit'] = bcadd($result['profit'], $detail['profit'], 2);
  173. $result['amount'] = bcadd($result['amount'], $detail['amount'], 2);
  174. $result['need_flow'] = bcadd($result['need_flow'], $detail['need_flow'], 2);
  175. $result['details'][] = $detail;
  176. return $result;
  177. }
  178. /** SUM() is an exact string on MySQL but may be a float on SQLite. */
  179. private static function money($value): string
  180. {
  181. if ($value === null || $value === '') return '0.00';
  182. if (is_float($value) || is_int($value)) $value = number_format((float) $value, 10, '.', '');
  183. return bcadd(EffectiveBet::decimal($value), '0', 2);
  184. }
  185. }