recalculate($synchronizer, (bool) $this->option('write')); $this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); return $result['skipped'] === 0 ? self::SUCCESS : self::FAILURE; } public function recalculate(BetFactSynchronizer $synchronizer, bool $write = false): array { $result = ['write' => $write, 'reviewed' => 0, 'changed' => 0, 'unchanged' => 0, 'skipped' => 0, 'paid_untouched' => 0, 'rows' => []]; if (!Schema::hasTable('rebates') || !Schema::hasTable('lhc_order') || !Schema::hasTable('lhc_order_item')) { throw new \RuntimeException('Rebate and current lottery source tables are required'); } $groups = []; $undatedMembers = []; DB::table('lhc_order')->whereIn('type', [5, 6])->orderBy('id')->chunkById(200, function ($orders) use ($synchronizer, &$groups, &$undatedMembers) { foreach ($orders as $order) { $member = (string) $order->member_id; try { $date = EffectiveBet::newYorkDate($order->created_at ?? null); } catch (\Throwable $e) { $undatedMembers[$member] = 'An original betting date is missing or invalid'; continue; } $key = $member . '\0' . $date; $groups[$key] ??= ['total' => EffectiveBet::zero(), 'effective' => EffectiveBet::zero(), 'errors' => []]; try { $snapshot = $synchronizer->lotteryOrder($order); if ($snapshot['facts'] === []) throw new \RuntimeException('Source member is demo or unavailable'); if ($snapshot['unsettled']) throw new \RuntimeException('Original betting day still has unfinished bets'); $groups[$key]['total'] = bcadd($groups[$key]['total'], $snapshot['total_bet'], EffectiveBet::SCALE); foreach ($snapshot['facts'] as $fact) { $groups[$key]['effective'] = bcadd($groups[$key]['effective'], $fact['effective_amount'], EffectiveBet::SCALE); } } catch (\Throwable $e) { $groups[$key]['errors'][] = 'order ' . $order->id . ': ' . $e->getMessage(); } } }); $result['paid_untouched'] = DB::table('rebates')->where('status', 1)->count(); DB::table('rebates')->where('status', 0)->orderBy('id')->chunkById(200, function ($rebates) use ($write, $groups, $undatedMembers, &$result) { foreach ($rebates as $rebate) { $result['reviewed']++; $key = (string) $rebate->member_id . '\0' . (string) $rebate->date; $group = $groups[$key] ?? null; $reason = $undatedMembers[(string) $rebate->member_id] ?? null; if ($reason === null && $group === null) $reason = 'No original-date source; legacy date cannot be inferred'; if ($reason === null && $group['errors'] !== []) $reason = implode('; ', $group['errors']); if ($reason === null && bccomp($group['total'], (string) $rebate->betting_amount, 2) !== 0) { $reason = 'Original-date stake total differs from stored betting_amount; date/refund history needs review'; } $row = ['id' => $rebate->id, 'member_id' => (string) $rebate->member_id, 'date' => $rebate->date, 'before' => bcadd((string) $rebate->effective_betting_amount, '0', 2), 'after' => null, 'status' => 'skipped', 'reason' => $reason]; if ($reason !== null) { $result['skipped']++; $result['rows'][] = $row; continue; } $row['after'] = bcadd($group['effective'], '0', 2); if (bccomp($row['before'], $row['after'], 2) === 0) { $row['status'] = 'unchanged'; $result['unchanged']++; } elseif (!$write) { $row['status'] = 'would_change'; $result['changed']++; } else { // Compare-and-set keeps a concurrent settlement/payment from being overwritten. $updated = DB::table('rebates')->where('id', $rebate->id)->where('status', 0) ->where('betting_amount', $rebate->betting_amount) ->where('effective_betting_amount', $rebate->effective_betting_amount) ->update(['effective_betting_amount' => $row['after']]); if ($updated === 1) { $row['status'] = 'changed'; $result['changed']++; } else { $row['status'] = 'skipped'; $row['reason'] = 'Rebate changed during recalculation; retry preview'; $result['skipped']++; } } $result['rows'][] = $row; } }); return $result; } }