PromotionService.php 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. <?php
  2. namespace App\Services;
  3. use App\Models\PromotionActivity;
  4. use App\Models\BalanceLog;
  5. use App\Models\User;
  6. use App\Models\Wallet;
  7. use App\Support\PromotionRules as Rules;
  8. use App\Support\PromotionTranslations;
  9. use App\Support\ActivityPeriod;
  10. use App\Support\FirstDepositChoice;
  11. use App\Services\Sharing\ShareDefaults;
  12. use Illuminate\Support\Facades\DB;
  13. use Illuminate\Support\Facades\Schema;
  14. use Illuminate\Validation\ValidationException;
  15. /** Backend jobs and deposit awards. Member HTTP APIs are owned by melbet_sport-api. */
  16. class PromotionService
  17. {
  18. private PromotionFacts $facts;
  19. public function __construct(?PromotionFacts $facts = null) { $this->facts = $facts ?? new PromotionFacts(); }
  20. public static function publish(PromotionActivity $activity): void
  21. {
  22. if (!Rules::supports((int)$activity->type)) return;
  23. $now = PromotionFacts::now();
  24. DB::table('promotion_campaigns')->insertOrIgnore(['activity_id' => $activity->id, 'first_published_at' => null, 'created_at' => $now, 'updated_at' => $now]);
  25. if ((int)$activity->status === 1) DB::table('promotion_campaigns')->where('activity_id', $activity->id)->whereNull('first_published_at')
  26. ->update(['first_published_at' => $now, 'updated_at' => $now]);
  27. }
  28. private function invalid(string $message): void { throw ValidationException::withMessages(['promotion' => $message]); }
  29. private function json($value): array { return is_array($value) ? $value : (json_decode((string)$value, true) ?: []); }
  30. private function rules(PromotionActivity $activity): array
  31. {
  32. $rules = Rules::normalize((int)$activity->type, $this->json($activity->params));
  33. return (int)$activity->type === Rules::FIRST_DEPOSIT ? Rules::withForbidden($rules) : $rules;
  34. }
  35. private function money(int $cents): string { return bcdiv((string)$cents, '100', 2); }
  36. public function heartbeat(int $userId, string $token, string $platform): array
  37. {
  38. $this->member($userId);
  39. $now = PromotionFacts::now(); $bucket = substr($now, 0, 16) . ':00';
  40. DB::table('promotion_presence')->insertOrIgnore(['user_id' => $userId, 'session_hash' => hash('sha256', $token . ':' . $platform), 'observed_at' => $bucket]);
  41. return ['recorded' => true, 'server_time' => $now, 'interval_seconds' => 60];
  42. }
  43. public function listing(int $userId, string $platform, string $lang = 'zh', ?int $type = null): array
  44. {
  45. $member = $this->member($userId); $context = ActivityEligibilityService::memberContext($member->member_id);
  46. $q = ActivityPeriod::active(PromotionActivity::where('status', 1), now()->timestamp);
  47. if ($type !== null) $q->where('type', $type);
  48. $data = [];
  49. foreach ($q->orderByDesc('sort')->orderByDesc('id')->get() as $activity) {
  50. $text = PromotionTranslations::select($activity->toArray(), $lang);
  51. if (!$text) continue;
  52. if (!ActivityEligibilityService::isPublishedOn($activity, $platform)) continue;
  53. $eligibility = ActivityEligibilityService::evaluate($activity, $context);
  54. if (!$eligibility['is_eligible'] && !$activity->show_ineligible) continue;
  55. $item = ['activity_id' => $activity->id, 'type' => (int)$activity->type, 'type_name' => $activity->type_name,
  56. 'start_time' => $activity->start_time === null ? null : (int)$activity->start_time, 'end_time' => $activity->end_time === null ? null : (int)$activity->end_time, 'rules' => $this->rules($activity)] + $text + $eligibility;
  57. if ((int) $activity->type === Rules::FIRST_DEPOSIT) $item['image'] = $activity->image;
  58. $data[] = $item;
  59. }
  60. return ['total' => count($data), 'data' => $data];
  61. }
  62. private function activity(int $id, bool $lock = false): PromotionActivity
  63. {
  64. $query = PromotionActivity::where('id', $id);
  65. $row = ($lock ? $query->lockForUpdate() : $query)->first();
  66. if (!$row || !Rules::supports((int)$row->type)) $this->invalid('活动不存在');
  67. return $row;
  68. }
  69. private function member(int $id): User
  70. {
  71. $member = User::find($id);
  72. if (!$member || (int)$member->from === 2 || !empty($member->getAttributes()['delete_time']) || (int)($member->getAttributes()['is_banned'] ?? 0) === 1) $this->invalid('会员不可参与活动');
  73. return $member;
  74. }
  75. private function open(PromotionActivity $activity, User $member, ?string $platform = null): string
  76. {
  77. $now = now('Asia/Shanghai')->timestamp;
  78. if ((int)$activity->status !== 1 || !ActivityPeriod::contains($activity->getRawOriginal('start_time'), $activity->getRawOriginal('end_time'), $now)) $this->invalid('活动未上架或不在活动时间内');
  79. $eligibility = ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id));
  80. if (!$eligibility['is_eligible']) $this->invalid($eligibility['ineligible_reason']);
  81. if ($platform !== null && !ActivityEligibilityService::isPublishedOn($activity, $platform)) $this->invalid('此客户端不可参与该活动');
  82. $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
  83. if (!$published) $this->invalid('活动尚未正式开启');
  84. return $published;
  85. }
  86. private function mutate(int $activityId, int $userId, callable $callback): array
  87. {
  88. return DB::transaction(function () use ($activityId, $userId, $callback) {
  89. $member = $this->member($userId);
  90. $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
  91. if (!$wallet) $this->invalid('会员钱包不存在');
  92. $activity = $this->activity($activityId, true);
  93. return $callback($activity, $member, $wallet);
  94. }, 3);
  95. }
  96. private function account(PromotionActivity $activity, User $member): object
  97. {
  98. $now = PromotionFacts::now();
  99. DB::table('promotion_accounts')->insertOrIgnore(['activity_id' => $activity->id, 'user_id' => $member->id,
  100. 'round_no' => 1, 'spins' => 0, 'created_at' => $now, 'updated_at' => $now]);
  101. return DB::table('promotion_accounts')->where('activity_id', $activity->id)->where('user_id', $member->id)->lockForUpdate()->first();
  102. }
  103. private function newRound(object $account, string $start, array $completed = [], bool $reset = false): object
  104. {
  105. $now = PromotionFacts::now();
  106. DB::table('promotion_rounds')->insert(['account_id' => $account->id, 'round_no' => $account->round_no,
  107. 'coins_cents' => 0, 'status' => 'active', 'started_at' => $start,
  108. 'deposit_cursor' => $reset ? (int)DB::table('balance_logs')->max('id') : 0,
  109. 'bet_cursors' => json_encode($reset ? $this->facts->cursors() : []), 'completed_tasks' => json_encode($completed), 'created_at' => $now, 'updated_at' => $now]);
  110. return $this->round($account);
  111. }
  112. private function round(object $account): ?object
  113. {
  114. return DB::table('promotion_rounds')->where('account_id', $account->id)->where('round_no', $account->round_no)->lockForUpdate()->first();
  115. }
  116. public function detail(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
  117. {
  118. $activity = $this->activity($activityId); $member = $this->member($userId);
  119. $this->open($activity, $member, $platform);
  120. $account = DB::table('promotion_accounts')->where('activity_id', $activityId)->where('user_id', $userId)->first();
  121. $round = $account ? DB::table('promotion_rounds')->where('account_id', $account->id)->where('round_no', $account->round_no)->first() : null;
  122. return $this->state($activity, $member, $account, $round, $lang);
  123. }
  124. public function progress(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
  125. {
  126. return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
  127. return $this->facts->snapshot(function () use ($activity, $member, $platform, $lang) {
  128. [$account, $round] = $this->refreshProgress($activity, $member, $platform);
  129. return $this->state($activity, $member, $account, $round, $lang);
  130. });
  131. });
  132. }
  133. /** Background synchronization needs task writes, without building member-facing daily metrics. */
  134. public function syncAccount(int $activityId, int $userId): void
  135. {
  136. $this->mutate($activityId, $userId, fn ($activity, $member) => $this->facts->snapshot(
  137. fn () => $this->refreshProgress($activity, $member)
  138. ));
  139. }
  140. private function refreshProgress(PromotionActivity $activity, User $member, ?string $platform = null): array
  141. {
  142. $start = $this->open($activity, $member, $platform); $account = $this->account($activity, $member); $round = null;
  143. if ((int)$activity->type === Rules::WHEEL) {
  144. $round = $this->round($account) ?? $this->newRound($account, $start);
  145. if ($round->status === 'active') $this->refreshWheel($activity, $member, $account, $round);
  146. }
  147. return [$account, $round];
  148. }
  149. public function apply(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
  150. {
  151. return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
  152. $start = $this->open($activity, $member, $platform);
  153. if ((int)$activity->type !== Rules::LOSS) $this->invalid('只有净亏邀请活动需要申请');
  154. $account = $this->account($activity, $member);
  155. if (!$account->applied_at) {
  156. $start = $this->lossStart($account, $start);
  157. $rules = $this->rules($activity); $policy = $this->facts->policy();
  158. $profit = $this->facts->betting([$member->id], $start)['profit'];
  159. $loss = bccomp($profit, '0', 10) < 0 ? bcsub('0', $profit, 10) : '0';
  160. if (bccomp($loss, $rules['loss_amount'], 10) < 0) $this->invalid('尚未达到净亏申请门槛');
  161. $account->applied_at = PromotionFacts::now();
  162. $account->application_snapshot = json_encode(['loss' => $loss, 'from' => $start, 'policy' => $policy, 'rules' => $rules,
  163. 'invite_user_cursor' => (int)DB::table('users')->max('id')]);
  164. DB::table('promotion_accounts')->where('id', $account->id)->update(['applied_at' => $account->applied_at,
  165. 'application_snapshot' => $account->application_snapshot, 'updated_at' => PromotionFacts::now()]);
  166. }
  167. return $this->state($activity, $member, $account, null, $lang);
  168. });
  169. }
  170. private function lossInvitees(User $member, object $account): array
  171. {
  172. if (!$account->applied_at) return [];
  173. $policy = $this->facts->policy(); $qualified = []; $snapshot = $this->json($account->application_snapshot);
  174. foreach ($this->facts->children($member->id) as $child) {
  175. if ((int)$child['id'] > ($snapshot['invite_user_cursor'] ?? 0) && $child['created_at'] >= $account->applied_at && $child['bound_at'] >= $account->applied_at && $this->facts->qualified($child, $policy)) $qualified[] = (int)$child['id'];
  176. }
  177. return $qualified;
  178. }
  179. /** Each loss-invite round counts net loss from the previous claim; the first round from the campaign's first publication. */
  180. private function lossStart(object $account, string $published): string
  181. {
  182. if ((int)$account->round_no <= 1) return $published;
  183. return (string)(DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', (int)$account->round_no - 1)->value('created_at') ?: $published);
  184. }
  185. private function grantTask(object $account, object $round, string $task, string $event, array $evidence, array &$known): bool
  186. {
  187. $key = $task . ':' . $event;
  188. if (isset($known[$key])) return false;
  189. $inserted = DB::table('promotion_task_grants')->insertOrIgnore(['account_id' => $account->id, 'round_no' => $round->round_no,
  190. 'task' => $task, 'event_key' => $event, 'evidence' => json_encode($evidence), 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
  191. $known[$key] = true;
  192. if ($inserted) { ++$account->spins; DB::table('promotion_accounts')->where('id', $account->id)->update(['spins' => $account->spins, 'updated_at' => PromotionFacts::now()]); }
  193. return $inserted > 0;
  194. }
  195. private function refreshWheel(PromotionActivity $activity, User $member, object $account, object $round): void
  196. {
  197. $this->facts->snapshot(fn () => $this->refreshWheelTasks($activity, $member, $account, $round));
  198. }
  199. private function refreshWheelTasks(PromotionActivity $activity, User $member, object $account, object $round): void
  200. {
  201. $rules = $this->rules($activity); $policy = $this->facts->policy();
  202. $completed = $this->json($round->completed_tasks);
  203. $grants = DB::table('promotion_task_grants')->where('account_id', $account->id)
  204. ->where(fn ($q) => $q->where('round_no', $round->round_no)->orWhereIn('task', ['login', 'online']))->get();
  205. $known = [];
  206. foreach ($grants as $grant) $known[$grant->task . ':' . $grant->event_key] = true;
  207. $roundGrants = $grants->where('round_no', $round->round_no)->groupBy('task');
  208. $campaignStart = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
  209. // Daily tasks are campaign-wide. They survive round reset, but never grant twice on one day.
  210. foreach ($this->facts->logins($member->member_id, $campaignStart) as $day => $_) {
  211. $this->grantTask($account, $round, 'login', $day, ['day' => $day], $known); $completed['login'] = true;
  212. }
  213. foreach ($this->facts->online($member->id, $campaignStart) as $day => $seconds) if ($seconds >= $rules['online_seconds']) {
  214. $this->grantTask($account, $round, 'online', $day, ['day' => $day, 'seconds' => $seconds], $known); $completed['online'] = true;
  215. }
  216. $children = $this->facts->children($member->id);
  217. $newInvite = false;
  218. foreach ($children as $child) {
  219. if ($this->facts->qualified($child, $policy) && !$this->facts->qualified($child, $policy, $round->started_at)) {
  220. $newInvite = $this->grantTask($account, $round, 'invite', (string)$child['id'], ['user_id' => $child['id'], 'policy' => $policy], $known) || $newInvite;
  221. }
  222. }
  223. // Only a grant belonging to this round can satisfy its resettable invitation task.
  224. $completed['invite'] = $newInvite; $childrenById = array_column($children, null, 'id');
  225. foreach ($roundGrants->get('invite', []) as $grant) {
  226. $evidence = $this->json($grant->evidence); $child = $childrenById[$evidence['user_id']] ?? null;
  227. if ($child && $this->facts->qualified($child, $evidence['policy'])) $completed['invite'] = true;
  228. }
  229. $cursors = $this->json($round->bet_cursors);
  230. $teamDeposits = $teamBets = [];
  231. foreach ($children as $child) {
  232. $from = max($round->started_at, $child['bound_at']);
  233. foreach ($this->facts->deposits([$child['member_id']], $from, null, $round->deposit_cursor) as $day => $amount) $teamDeposits[$day] = bcadd($teamDeposits[$day] ?? '0', $amount, 10);
  234. foreach ($this->facts->betting([$child['id']], $from, null, $cursors)['effective'] as $day => $amount) $teamBets[$day] = bcadd($teamBets[$day] ?? '0', $amount, 10);
  235. }
  236. $metrics = [
  237. 'deposit' => $this->facts->deposits([$member->member_id], $round->started_at, null, $round->deposit_cursor),
  238. 'team_deposit' => $teamDeposits,
  239. 'bet' => $this->facts->betting([$member->id], $round->started_at, null, $cursors)['effective'],
  240. 'team_bet' => $teamBets,
  241. ];
  242. foreach ($metrics as $task => $days) {
  243. // Recompute qualification after corrections; daily amounts never carry across midnight.
  244. $completed[$task] = false;
  245. foreach ($days as $day => $amount) if (bccomp($amount, $rules[$task . '_amount'], 10) >= 0) {
  246. $this->grantTask($account, $round, $task, $round->round_no . ':' . $day, ['day' => $day, 'amount' => $amount, 'target' => $rules[$task . '_amount']], $known); $completed[$task] = true;
  247. }
  248. foreach ($roundGrants->get($task, []) as $grant) {
  249. $proof = $this->json($grant->evidence);
  250. if (bccomp($days[$proof['day']] ?? '0', $proof['target'] ?? $rules[$task . '_amount'], 10) >= 0) $completed[$task] = true;
  251. }
  252. }
  253. $encoded = json_encode($completed);
  254. if ($encoded !== $round->completed_tasks) DB::table('promotion_rounds')->where('id', $round->id)->update(['completed_tasks' => $encoded, 'updated_at' => PromotionFacts::now()]);
  255. $round->completed_tasks = $encoded;
  256. }
  257. /** $budget defaults to $amount; coupon rewards also charge the invitee coupons they issue. */
  258. private function reserve(PromotionActivity $activity, User $member, object $account, int $roundNo, string $amount, array $rules, ?string $budget = null): object
  259. {
  260. $budget ??= $amount;
  261. $this->capacity($activity, $member, $budget);
  262. $received = DB::table('promotion_rewards')->where('activity_id', $activity->id)->where('user_id', $member->id)->exists();
  263. DB::table('promotion_rewards')->insert(['activity_id' => $activity->id, 'user_id' => $member->id, 'account_id' => $account->id,
  264. 'round_no' => $roundNo, 'amount' => $amount,
  265. 'required_flow' => ($rules['reward_mode'] ?? 'cash') === 'coupon' ? '0.00' : bcmul($amount, $rules['turnover_ratio'], 2),
  266. 'status' => 'reserved', 'rule_snapshot' => json_encode($rules), 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
  267. if (!$received) ++$activity->rewarded_count;
  268. $activity->budget_used = bcadd((string)$activity->budget_used, $budget, 2); $activity->save();
  269. return DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', $roundNo)->first();
  270. }
  271. private function capacity(PromotionActivity $activity, User $member, string $amount): void
  272. {
  273. $received = DB::table('promotion_rewards')->where('activity_id', $activity->id)->where('user_id', $member->id)->exists();
  274. if (!$received && $activity->reward_quota !== null && (int)$activity->rewarded_count >= (int)$activity->reward_quota) $this->invalid('活动获奖份数已用完');
  275. if ($activity->budget !== null && bccomp(bcadd((string)$activity->budget_used, $amount, 2), (string)$activity->budget, 2) > 0) $this->invalid('活动奖励额度不足');
  276. }
  277. public function spin(int $activityId, int $userId, string $requestKey, ?string $platform = null): array
  278. {
  279. return $this->mutate($activityId, $userId, function ($activity, $member) use ($requestKey, $platform) {
  280. $account = $this->account($activity, $member);
  281. $previous = DB::table('promotion_spins')->where('account_id', $account->id)->where('request_key', $requestKey)->first();
  282. if ($previous) return ['spin' => $this->spinRow($previous), 'spins' => $account->spins, 'replayed' => true];
  283. $start = $this->open($activity, $member, $platform);
  284. if ((int)$activity->type !== Rules::WHEEL) $this->invalid('该活动不是转盘活动');
  285. $round = $this->round($account) ?? $this->newRound($account, $start);
  286. if ($round->status !== 'active') $this->invalid('本轮已达到666,请先领取');
  287. $this->refreshWheel($activity, $member, $account, $round);
  288. if ($account->spins < 1) $this->invalid('抽奖次数不足');
  289. $rules = $this->rules($activity); $flags = $this->json($round->completed_tasks);
  290. $complete = count(array_filter(array_intersect_key($flags, array_flip(Rules::TASKS)))) === count(Rules::TASKS);
  291. // Capacity is checked before random sampling; a winning outcome is reserved immediately.
  292. if ($complete && bccomp($rules['completion_probability'], '0', 2) > 0) $this->capacity($activity, $member, '666.00');
  293. $draw = Rules::draw((int)$round->coins_cents, $complete, $rules['completion_probability']);
  294. if ($draw['reached']) $this->reserve($activity, $member, $account, $round->round_no, '666.00', $rules);
  295. $coins = $round->coins_cents + $draw['amount_cents']; $now = PromotionFacts::now();
  296. $id = DB::table('promotion_spins')->insertGetId(['account_id' => $account->id, 'round_no' => $round->round_no, 'request_key' => $requestKey,
  297. 'prize_index' => $draw['prize_index'], 'amount_cents' => $draw['amount_cents'], 'coins_cents' => $coins,
  298. 'conditions_complete' => $complete, 'probability' => $complete ? $rules['completion_probability'] : '0.00', 'created_at' => $now, 'updated_at' => $now]);
  299. DB::table('promotion_rounds')->where('id', $round->id)->update(['coins_cents' => $coins, 'status' => $draw['reached'] ? 'reached' : 'active', 'updated_at' => $now]);
  300. --$account->spins;
  301. DB::table('promotion_accounts')->where('id', $account->id)->update(['spins' => $account->spins, 'updated_at' => $now]);
  302. return ['spin' => $this->spinRow(DB::table('promotion_spins')->where('id', $id)->first()), 'spins' => $account->spins, 'replayed' => false];
  303. });
  304. }
  305. private function pay(object $reward, User $member, Wallet $wallet): object
  306. {
  307. if ($reward->status !== 'reserved') return $reward;
  308. // 活动奖金直接进入可用余额;设置了流水倍数的奖励把 required_flow 计入会员提现打码量。
  309. $before = (string)$wallet->available_balance; $wallet->available_balance = bcadd($before, (string)$reward->amount, 10); $wallet->save();
  310. $log = BalanceLog::create(['member_id' => $member->member_id, 'type' => 1, 'change_type' => '优惠活动',
  311. 'amount' => $reward->amount, 'before_balance' => $before, 'after_balance' => $wallet->available_balance, 'related_id' => $reward->id,
  312. 'frozen_status' => null, 'remark' => '新活动奖励; activity=' . $reward->activity_id . '; round=' . $reward->round_no]);
  313. WageringService::addRequirement($wallet, (string)$reward->required_flow, 'activity:promotion', $reward->id,
  314. '新活动奖励; activity=' . $reward->activity_id . '; round=' . $reward->round_no);
  315. $now = PromotionFacts::now();
  316. DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'credited_log_id' => $log->id,
  317. 'flow_snapshot' => json_encode(['from' => $now, 'cursors' => $this->facts->cursors()]), 'updated_at' => $now]);
  318. return DB::table('promotion_rewards')->where('id', $reward->id)->first();
  319. }
  320. /** Coupon rewards credit no money; the reserved face value is consumed by the coupons themselves. */
  321. private function issueCoupons(object $reward, User $member): object
  322. {
  323. if ($reward->status !== 'reserved') return $reward;
  324. $rules = $this->json($reward->rule_snapshot); $now = PromotionFacts::now();
  325. $holders = [[(int)$member->id, (string)$member->member_id, 'inviter', $rules['inviter_coupons']]];
  326. foreach (DB::table('users')->whereIn('id', $rules['coupon_invitees'] ?: [0])->orderBy('id')->get(['id', 'member_id']) as $child) {
  327. $holders[] = [(int)$child->id, (string)$child->member_id, 'invitee', $rules['invitee_coupons']];
  328. }
  329. $rows = [];
  330. foreach ($holders as [$userId, $memberId, $role, $tiers]) {
  331. $seq = 0;
  332. foreach ($tiers as $tier) for ($i = 0; $i < $tier['quantity']; $i++) {
  333. $rows[] = ['activity_id' => $reward->activity_id, 'reward_id' => $reward->id, 'user_id' => $userId, 'member_id' => $memberId,
  334. 'role' => $role, 'inviter_user_id' => $member->id, 'seq' => ++$seq, 'min_amount' => $tier['min_amount'], 'discount' => $tier['discount'],
  335. 'categories' => json_encode($rules['coupon_categories']), 'valid_from' => $rules['coupon_valid_from'], 'valid_until' => $rules['coupon_valid_until'],
  336. 'status' => 'unused', 'created_at' => $now, 'updated_at' => $now];
  337. }
  338. }
  339. foreach (array_chunk($rows, 500) as $chunk) DB::table('promotion_coupons')->insertOrIgnore($chunk);
  340. DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'updated_at' => $now]);
  341. return DB::table('promotion_rewards')->where('id', $reward->id)->first();
  342. }
  343. public function claim(int $activityId, int $userId, ?int $roundNo = null, ?string $platform = null): array
  344. {
  345. return $this->mutate($activityId, $userId, function ($activity, $member, $wallet) use ($roundNo, $platform) {
  346. $account = $this->account($activity, $member);
  347. // Loss invite ignores the client's round: it claims the current round, or replays the last claim when a retry
  348. // arrives after the account already moved on to an unapplied next round.
  349. if ((int)$activity->type === Rules::LOSS) $roundNo = (int)$account->round_no - (!$account->applied_at && (int)$account->round_no > 1 ? 1 : 0);
  350. elseif ($roundNo === null) $this->invalid('round_no必须为正整数');
  351. $reward = DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', $roundNo)->lockForUpdate()->first();
  352. if ($reward && $reward->status !== 'reserved') return ['reward' => (array)$reward, 'round_no' => $account->round_no, 'spins' => $account->spins, 'replayed' => true];
  353. if ((int)$activity->type === Rules::LOSS) {
  354. $this->open($activity, $member, $platform);
  355. if ($roundNo !== (int)$account->round_no || !$account->applied_at) $this->invalid('请先申请活动');
  356. $snapshot = $this->json($account->application_snapshot); $rules = $snapshot['rules'];
  357. $qualified = $this->lossInvitees($member, $account);
  358. if (count($qualified) < $rules['invite_count']) $this->invalid('有效邀请人数不足');
  359. // The application is cleared for the next round, so the reward keeps it for history.
  360. $history = ['qualified_invitees' => $qualified, 'application' => ['applied_at' => $account->applied_at] + array_diff_key($snapshot, ['rules' => true])];
  361. if (($rules['reward_mode'] ?? 'cash') === 'coupon') {
  362. // Exactly the invitees counted toward the target receive invitee coupons, earliest registrations first.
  363. sort($qualified); $counted = array_slice($qualified, 0, $rules['invite_count']);
  364. // The reward amount is the inviter's own coupons; invitee coupons only count toward the budget.
  365. $own = Rules::faceValue($rules['inviter_coupons']);
  366. $total = bcadd($own, bcmul(Rules::faceValue($rules['invitee_coupons']), (string)count($counted), 2), 2);
  367. $reward ??= $this->reserve($activity, $member, $account, $roundNo, $own, $rules + $history + ['coupon_invitees' => $counted, 'coupon_face_value' => $total], $total);
  368. } else $reward ??= $this->reserve($activity, $member, $account, $roundNo, $rules['reward_amount'], $rules + $history);
  369. } elseif ((int)$activity->type === Rules::WHEEL) {
  370. // A reserved, earned reward remains payable after the campaign is unpublished.
  371. $round = $this->round($account);
  372. if (!$reward || !$round || $round->round_no !== $roundNo || $round->status !== 'reached') $this->invalid('本轮尚未达到666');
  373. } else $this->invalid('首充奖励由充值成功后自动发放');
  374. $reward = ($this->json($reward->rule_snapshot)['reward_mode'] ?? 'cash') === 'coupon' ? $this->issueCoupons($reward, $member) : $this->pay($reward, $member, $wallet);
  375. if ((int)$activity->type === Rules::LOSS) {
  376. // Loss invite repeats: the next round needs a new application, net loss from now and new invitees.
  377. ++$account->round_no;
  378. DB::table('promotion_accounts')->where('id', $account->id)->update(['round_no' => $account->round_no, 'applied_at' => null,
  379. 'application_snapshot' => null, 'updated_at' => PromotionFacts::now()]);
  380. }
  381. if ((int)$activity->type === Rules::WHEEL) {
  382. $flags = array_intersect_key($this->json($round->completed_tasks), array_flip(Rules::DAILY));
  383. DB::table('promotion_rounds')->where('id', $round->id)->update(['status' => 'claimed', 'claimed_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
  384. ++$account->round_no;
  385. DB::table('promotion_accounts')->where('id', $account->id)->update(['round_no' => $account->round_no, 'updated_at' => PromotionFacts::now()]);
  386. $this->newRound($account, PromotionFacts::now(), $flags, true);
  387. }
  388. return ['reward' => (array)$reward, 'round_no' => $account->round_no, 'spins' => $account->spins, 'replayed' => false];
  389. });
  390. }
  391. public function onDeposit(BalanceLog $deposit, ?string $orderSource = null): void
  392. {
  393. if (!DB::connection()->getSchemaBuilder()->hasTable('promotion_first_deposits') || (int)$deposit->type !== 1
  394. || !in_array($deposit->change_type, FirstDepositChoice::DEPOSIT_TYPES, true) || bccomp((string)$deposit->amount, '0', 10) <= 0) return;
  395. $member = User::where('member_id', $deposit->member_id)->first();
  396. if (!$member || (int)$member->from === 2) return;
  397. DB::transaction(function () use ($member, $deposit, $orderSource) {
  398. $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
  399. if (!$wallet) $this->invalid('会员钱包不存在');
  400. $first = DB::table('balance_logs')->where('member_id', $member->member_id)->where('type', 1)->where('amount', '>', 0)
  401. ->whereIn('change_type', FirstDepositChoice::DEPOSIT_TYPES)->orderBy('created_at')->orderBy('id')->lockForUpdate()->value('id');
  402. if ((int)$first !== (int)$deposit->id) return;
  403. $inserted = DB::table('promotion_first_deposits')->insertOrIgnore(['user_id' => $member->id, 'deposit_log_id' => $deposit->id,
  404. 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
  405. if (!$inserted) return;
  406. $choice = FirstDepositChoice::selection((int) $member->id, true);
  407. if ($choice && !(int) $choice->joined) return;
  408. $activityId = (int) ($choice->activity_id ?? 0);
  409. if (!$activityId && FirstDepositChoice::ready() && !FirstDepositChoice::legacyOrder($orderSource, (int) $deposit->related_id)) return;
  410. // 多个首充活动按档位发放:在所有可参加的首充活动里,取充值金额达到门槛、奖励最高的一个(份数或额度用完时取下一档)。
  411. // 会员确认的禁止游戏大类(全部首充活动统一)沿用确认时的快照;选中的那个活动沿用确认时的完整规则。
  412. // 只在会员确认时所在的端(PC/H5/APP/TG)发布的活动里选;上线前的选择没有记录端,按会员确认的那个活动发布的端。
  413. $snapshot = $activityId ? $this->json($choice->rule_snapshot) : null;
  414. $platforms = isset($snapshot['platform']) ? [$snapshot['platform']] : [];
  415. unset($snapshot['platform']);
  416. if ($activityId && !$platforms && ($confirmed = PromotionActivity::find($activityId))) {
  417. $platforms = array_values(array_filter(['PC', 'H5', 'APP', 'TG'], fn ($p) => ActivityEligibilityService::isPublishedOn($confirmed, $p)));
  418. }
  419. $ids = ActivityPeriod::active(PromotionActivity::where('type', Rules::FIRST_DEPOSIT)->where('status', 1), now()->timestamp)
  420. ->orderByDesc('sort')->orderByDesc('id')->pluck('id')->all();
  421. if ($activityId) $ids = array_values(array_unique(array_merge([$activityId], $ids)));
  422. $creditedAt = (string)$deposit->getRawOriginal('created_at');
  423. $candidates = [];
  424. foreach ($ids as $id) {
  425. $activity = PromotionActivity::whereKey($id)->lockForUpdate()->first();
  426. if (!$activity || (int)$activity->status !== 1 || (int)$activity->type !== Rules::FIRST_DEPOSIT
  427. || !ActivityPeriod::contains($activity->start_time, $activity->end_time, now()->timestamp)) continue;
  428. if ((int)$activity->id !== $activityId && !PromotionTranslations::select($activity->toArray(), $member->language ?: 'zh')) continue;
  429. if ($activityId && (int)$activity->id !== $activityId
  430. && !array_filter($platforms, fn ($p) => ActivityEligibilityService::isPublishedOn($activity, $p))) continue;
  431. $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->sharedLock()->value('first_published_at');
  432. if (!$published || $creditedAt < $published || !ActivityPeriod::contains($activity->start_time, $activity->end_time, strtotime($creditedAt))) continue;
  433. if (!ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id))['is_eligible']) continue;
  434. // Pre-cutover orders keep their legacy reward without new restrictions.
  435. if ($snapshot === null) $rules = array_replace($this->rules($activity), ['forbidden_categories' => []]);
  436. elseif ((int)$activity->id === $activityId) $rules = $snapshot;
  437. else $rules = array_replace($this->rules($activity), ['forbidden_categories' => $snapshot['forbidden_categories'] ?? []]);
  438. if (!$rules || bccomp((string)$deposit->amount, (string)$rules['min_deposit'], 10) < 0) continue;
  439. $candidates[] = [$activity, $rules];
  440. }
  441. // 奖励高的优先;奖励相同时会员确认的活动优先,其次按活动排序。
  442. usort($candidates, fn ($a, $b) => bccomp((string)$b[1]['reward_amount'], (string)$a[1]['reward_amount'], 10)
  443. ?: (((int)$b[0]->id === $activityId) <=> ((int)$a[0]->id === $activityId)));
  444. foreach ($candidates as [$activity, $rules]) {
  445. try { $this->capacity($activity, $member, $rules['reward_amount']); } catch (ValidationException $e) { continue; }
  446. $account = $this->account($activity, $member);
  447. $reward = $this->reserve($activity, $member, $account, 1, $rules['reward_amount'], $rules + ['deposit_log_id' => $deposit->id]);
  448. $this->pay($reward, $member, $wallet); break;
  449. }
  450. }, 3);
  451. }
  452. /** Called inside the Telegram bet transaction after locking the member's wallet. */
  453. public function lotteryRestricted(string $memberId): bool
  454. {
  455. if (!FirstDepositChoice::ready()) return false;
  456. $userId = DB::table('users')->where('member_id', $memberId)->value('id');
  457. $rewards = DB::table('promotion_rewards')->where('user_id', $userId)->whereIn('status', ['paid', 'locked'])
  458. ->where('required_flow', '>', 0)->sharedLock()->get();
  459. $categories = array_keys(ShareDefaults::commissionCategories());
  460. foreach ($rewards as $reward) {
  461. $configured = (array) ($this->json($reward->rule_snapshot)['forbidden_categories'] ?? []);
  462. $forbidden = array_intersect($categories, array_filter($configured, 'is_string'));
  463. if (!in_array('self:lottery', $forbidden, true) || count($forbidden) === count($categories)) continue;
  464. $activity = DB::table('promotion_activities')->where('id', $reward->activity_id)->where('type', Rules::FIRST_DEPOSIT)
  465. ->where('status', 1)->sharedLock()->first(['start_time', 'end_time']);
  466. if (!$activity || !ActivityPeriod::contains($activity->start_time, $activity->end_time, now()->timestamp)) continue;
  467. $flow = $this->json($reward->flow_snapshot);
  468. $from = $flow['from'] ?? $reward->created_at;
  469. $completed = $this->facts->total($this->facts->betting([(int) $userId], $from, null, $flow['cursors'] ?? [])['effective']);
  470. if (bccomp($completed, (string) $reward->required_flow, 10) < 0) return true;
  471. }
  472. return false;
  473. }
  474. private function state(PromotionActivity $activity, User $member, ?object $account, ?object $round, ?string $lang = null): array
  475. {
  476. $rules = $this->rules($activity); $type = (int)$activity->type;
  477. $text = PromotionTranslations::select($activity->toArray(), $lang ?? ($member->language ?: 'zh'));
  478. if (!$text) $this->invalid('活动未配置所选语言');
  479. $result = ['activity_id' => $activity->id, 'type' => $type,
  480. 'start_time' => $activity->start_time === null ? null : (int)$activity->start_time,
  481. 'end_time' => $activity->end_time === null ? null : (int)$activity->end_time,
  482. 'rules' => $rules, 'account_id' => $account->id ?? null,
  483. 'round_no' => $account->round_no ?? 1, 'spins' => $account->spins ?? 0, 'server_time' => PromotionFacts::now()] + $text;
  484. if ($type === Rules::WHEEL) {
  485. $flags = $round ? $this->json($round->completed_tasks) : [];
  486. $result += ['coins' => $this->money($round->coins_cents ?? 0), 'remaining' => $this->money(66600 - ($round->coins_cents ?? 0)),
  487. 'status' => $round->status ?? 'not_started', 'completed_tasks' => $flags,
  488. 'conditions_complete' => count(array_filter(array_intersect_key($flags, array_flip(Rules::TASKS)))) === 7,
  489. 'prizes' => Rules::PRIZES, 'valid_member_policy' => $this->facts->policy(),
  490. 'ordinary_pool_rule' => Rules::ORDINARY_POOL_RULE];
  491. $today = substr(PromotionFacts::now(), 0, 10);
  492. $result['today_granted_tasks'] = $account ? DB::table('promotion_task_grants')->where('account_id', $account->id)
  493. ->where(function ($q) use ($today, $account) { $q->where('event_key', $today)->orWhere('event_key', $account->round_no . ':' . $today); })->pluck('task')->all() : [];
  494. $result['tasks'] = $this->wheelToday($activity, $member, $account, $round, $flags, $result['today_granted_tasks']);
  495. } elseif ($type === Rules::LOSS) {
  496. $start = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
  497. if ($account && $start) $start = $this->lossStart($account, $start);
  498. $profit = $this->facts->betting([$member->id], $start)['profit'];
  499. $result += ['loss' => bccomp($profit, '0', 10) < 0 ? bcsub('0', $profit, 2) : '0.00', 'applied_at' => $account->applied_at ?? null,
  500. 'qualified_invitees' => $account ? count($this->lossInvitees($member, $account)) : 0, 'valid_member_policy' => $this->facts->policy()];
  501. } else {
  502. $result['has_deposited'] = DB::table('balance_logs')->where('member_id', $member->member_id)->where('type', 1)->where('amount', '>', 0)->whereIn('change_type', ['充值', '人工充值', '三方充值'])->exists();
  503. }
  504. $result['rewards'] = $account ? DB::table('promotion_rewards')->where('account_id', $account->id)->orderByDesc('round_no')->limit(10)->get()->map(fn ($r) => (array)$r)->all() : [];
  505. if ($type === Rules::LOSS) {
  506. // Applications made before coupon mode existed snapshot cash rules without the coupon keys.
  507. if ($account?->applied_at) $result['rules'] = array_replace(Rules::defaults(Rules::LOSS), $this->json($account->application_snapshot)['rules']);
  508. $result['coupons'] = $this->couponSummary((int)$activity->id, (int)$member->id);
  509. // Status is for the current round; earlier rounds are in rewards.
  510. $result['status'] = !$account?->applied_at ? 'not_applied' : ($result['qualified_invitees'] >= $result['rules']['invite_count'] ? 'ready' : 'collecting');
  511. $result['loss_from'] = $start;
  512. }
  513. return $result;
  514. }
  515. /** The member's own coupons from this activity, grouped by tier. */
  516. private function couponSummary(int $activityId, int $userId): array
  517. {
  518. return DB::table('promotion_coupons')->where('activity_id', $activityId)->where('user_id', $userId)
  519. ->selectRaw("role, min_amount, discount, valid_from, valid_until, count(*) as quantity, sum(case when status = 'unused' then 1 else 0 end) as unused")
  520. ->groupBy('role', 'min_amount', 'discount', 'valid_from', 'valid_until')->orderBy('role')->orderBy('min_amount')->get()
  521. ->map(fn ($r) => ['role' => $r->role, 'min_amount' => (string)$r->min_amount, 'discount' => (string)$r->discount, 'valid_from' => $r->valid_from,
  522. 'valid_until' => $r->valid_until, 'quantity' => (int)$r->quantity, 'unused' => (int)$r->unused])->all();
  523. }
  524. private function wheelToday(PromotionActivity $activity, User $member, ?object $account, ?object $round, array $flags, array $granted): array
  525. {
  526. $rules = $this->rules($activity); $today = substr(PromotionFacts::now(), 0, 10);
  527. $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
  528. $dailyFrom = max($published, $today . ' 00:00:00'); $from = max($round->started_at ?? $published, $dailyFrom);
  529. $cursors = $round ? $this->json($round->bet_cursors) : []; $cursor = (int)($round->deposit_cursor ?? 0);
  530. $online = $this->facts->online($member->id, $dailyFrom); $logins = $this->facts->logins($member->member_id, $dailyFrom);
  531. $values = ['online' => $online[$today] ?? 0, 'login' => isset($logins[$today]) ? 1 : 0,
  532. 'invite' => $account ? DB::table('promotion_task_grants')->where('account_id', $account->id)->where('round_no', $account->round_no)->where('task', 'invite')->count() : 0,
  533. 'deposit' => $this->facts->total($this->facts->deposits([$member->member_id], $from, null, $cursor)),
  534. 'team_deposit' => '0', 'bet' => $this->facts->total($this->facts->betting([$member->id], $from, null, $cursors)['effective']), 'team_bet' => '0'];
  535. foreach ($this->facts->children($member->id) as $child) {
  536. $childFrom = max($from, $child['bound_at']);
  537. $values['team_deposit'] = bcadd($values['team_deposit'], $this->facts->total($this->facts->deposits([$child['member_id']], $childFrom, null, $cursor)), 10);
  538. $values['team_bet'] = bcadd($values['team_bet'], $this->facts->total($this->facts->betting([$child['id']], $childFrom, null, $cursors)['effective']), 10);
  539. }
  540. $tasks = [];
  541. foreach (Rules::TASKS as $key) {
  542. $target = match ($key) { 'online' => $rules['online_seconds'], 'login', 'invite' => 1, default => $rules[$key . '_amount'] };
  543. $tasks[] = ['key' => $key, 'round_completed' => (bool)($flags[$key] ?? false), 'value' => $values[$key], 'target' => $target,
  544. 'today_rewarded' => in_array($key, $granted, true), 'reward_spins' => 1,
  545. 'remaining_seconds' => $key === 'online' ? max(0, $target - $values[$key]) : null];
  546. }
  547. return $tasks;
  548. }
  549. private function spinRow(object $row): array
  550. {
  551. return ['id' => $row->id, 'round_no' => $row->round_no, 'prize_index' => $row->prize_index, 'prize' => Rules::PRIZES[$row->prize_index],
  552. 'amount' => $this->money($row->amount_cents), 'coins' => $this->money($row->coins_cents), 'reached' => (int)$row->coins_cents === 66600,
  553. 'conditions_complete' => (bool)$row->conditions_complete, 'probability' => (string)$row->probability, 'created_at' => $row->created_at];
  554. }
  555. public function records(int $activityId, int $userId, int $page = 1, int $limit = 20): array
  556. {
  557. $this->activity($activityId); $this->member($userId);
  558. $account = DB::table('promotion_accounts')->where('activity_id', $activityId)->where('user_id', $userId)->first();
  559. if (!$account) return ['total' => 0, 'data' => []];
  560. $q = DB::table('promotion_spins')->where('account_id', $account->id);
  561. return ['total' => $q->count(), 'data' => $q->orderByDesc('id')->forPage($page, $limit)->get()->map(fn ($r) => $this->spinRow($r))->all()];
  562. }
  563. public function broadcasts(int $activityId): array
  564. {
  565. $activity = $this->activity($activityId);
  566. if ((int)$activity->type !== Rules::WHEEL) $this->invalid('该活动不是转盘活动');
  567. $q = DB::table('promotion_spins as s')->join('promotion_accounts as a', 'a.id', '=', 's.account_id')->join('users as u', 'u.id', '=', 'a.user_id')
  568. ->where('a.activity_id', $activityId);
  569. $map = fn ($r) => ['account' => substr((string)$r->member_id, 0, strlen((string)$r->member_id) >= 10 ? 4 : 1) . '****' . substr((string)$r->member_id, -1),
  570. 'amount' => $this->money($r->amount_cents), 'created_at' => $r->created_at];
  571. return ['ordinary' => (clone $q)->where('s.amount_cents', '>', 0)->orderByDesc('s.id')->limit(100)->get(['u.member_id', 's.amount_cents', 's.created_at'])->map($map)->all(),
  572. 'reached_666' => $q->where('s.coins_cents', 66600)->orderByDesc('s.id')->limit(50)->get(['u.member_id', 's.amount_cents', 's.created_at'])->map(fn ($r) => array_replace($map($r), ['amount' => '666.00']))->all()];
  573. }
  574. public function rewards(int $activityId, int $userId, int $page = 1, int $limit = 20): array
  575. {
  576. $this->activity($activityId); $this->member($userId);
  577. $q = DB::table('promotion_rewards')->where('activity_id', $activityId)->where('user_id', $userId);
  578. return ['total' => $q->count(), 'data' => $q->orderByDesc('id')->forPage($page, $limit)->get()];
  579. }
  580. public function unlock(int $userId): int
  581. {
  582. return DB::transaction(function () use ($userId) {
  583. $member = $this->member($userId); $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
  584. if (!$wallet) return 0;
  585. $count = 0;
  586. foreach (DB::table('promotion_rewards')->where('user_id', $userId)->where('status', 'locked')->orderBy('id')->lockForUpdate()->get() as $reward) {
  587. $snapshot = $this->json($reward->flow_snapshot);
  588. $effective = $this->facts->total($this->facts->betting([$userId], $snapshot['from'], null, $snapshot['cursors'])['effective']);
  589. if (bccomp($effective, (string)$reward->required_flow, 10) < 0) continue;
  590. if (bccomp((string)$wallet->frozen_balance, (string)$reward->amount, 10) < 0) $this->invalid('冻结奖励余额不足');
  591. $beforeFrozen = (string)$wallet->frozen_balance; $before = (string)$wallet->available_balance;
  592. $wallet->frozen_balance = bcsub($beforeFrozen, (string)$reward->amount, 10); $wallet->available_balance = bcadd($before, (string)$reward->amount, 10); $wallet->save();
  593. BalanceLog::create(['member_id' => $member->member_id, 'type' => 2, 'change_type' => '流水解冻', 'amount' => bcsub('0', (string)$reward->amount, 2),
  594. 'before_balance' => $beforeFrozen, 'after_balance' => $wallet->frozen_balance, 'related_id' => $reward->id, 'remark' => '新活动奖励解冻']);
  595. $log = BalanceLog::create(['member_id' => $member->member_id, 'type' => 1, 'change_type' => '流水解冻', 'amount' => $reward->amount,
  596. 'before_balance' => $before, 'after_balance' => $wallet->available_balance, 'related_id' => $reward->id, 'remark' => '新活动奖励解冻']);
  597. DB::table('balance_logs')->where('id', $reward->credited_log_id)->update(['frozen_status' => 2]);
  598. DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'unlocked_log_id' => $log->id, 'unlocked_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]); ++$count;
  599. }
  600. return $count;
  601. }, 3);
  602. }
  603. }