['business' => '营业报表', 'promotion' => '推广报表', 'sharing' => '分享赚钱'], 'categories' => ['all' => '所有订单'] + MaintenancePolicy::CATEGORIES, 'platforms' => ['all', 'H5', 'PC', 'APP', 'unknown'], 'settlement_statuses' => ['all', 'pending', 'settled'], 'ranking_metrics' => ['deposit_amount', 'bet_amount', 'payout_amount', 'withdraw_amount', 'activity_minutes'], 'timezone' => 'Asia/Shanghai', 'currency' => (new ReportSync())->currency(), 'max_days' => 366, 'rules' => ['profit' => '已结算游戏毛利-实发优惠-代理佣金-分享佣金-实际平台手续费', 'net_cash_flow' => '成功收款-实际对外打款-实际平台手续费', 'valid_member' => '范围内期间非撤销投注会员,按查询结束日已生效的分享赚钱规则,累计成功存款及累计已结算非撤销同币种有效投注均大于或等于对应门槛;累计包含查询开始日前记录,不受装置及游戏分类筛选影响', 'online_rate' => '期间活跃会员/范围内期末真实会员', 'public_costs' => '公共资金和成本只在所有订单展示,未知值为null']]; } public function settings(?array $input = null): array { if ($input !== null) { throw ValidationException::withMessages(['valid_deposit_min' => '报表有效会员条件统一使用分享赚钱配置,请通过 /admin/shareEarning/settings/save 修改;报表设置仅供只读兼容']); } $settings = (array) DB::table('reporting_settings')->where('id', 1)->first(); $policy = (new ShareMemberQualification())->read(now('Asia/Shanghai')->toDateString(), false)['policy_meta']; return array_replace($settings, ['valid_deposit_min' => $policy['min_deposit'], 'valid_effective_bet_min' => $policy['min_effective_bet'], 'valid_member_policy' => $policy]); } public function reconciliations(array $input): array { $p = Validator::make($input, ['page' => ['integer', 'min:1', 'max:1000000'], 'limit' => ['integer', 'min:1', 'max:100'], 'status' => ['nullable', Rule::in(['all', 'pending', 'verified'])], 'kind' => ['nullable', Rule::in(['deposit', 'withdraw', 'agent_withdraw'])]])->validate(); $query = DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')->whereIn('e.kind', ['deposit', 'withdraw', 'agent_withdraw']); if (($p['status'] ?? 'all') === 'pending') $query->whereNull('r.id'); if (($p['status'] ?? 'all') === 'verified') $query->whereNotNull('r.id'); if (!empty($p['kind'])) $query->where('e.kind', $p['kind']); $total = (clone $query)->count(); return ['total' => $total, 'data' => $query->select('e.*', 'r.actual_amount', 'r.external_fee', 'r.occurred_at as verified_at', 'r.evidence') ->orderByDesc('e.id')->forPage((int) ($p['page'] ?? 1), (int) ($p['limit'] ?? 20))->get()->map(fn ($r) => (array) $r)->all()]; } public function reconcile(array $input): array { $rules = ['event_id' => ['required', 'integer', 'min:1'], 'actual_amount' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,10})?$/D'], 'external_fee' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,10})?$/D'], 'occurred_at' => ['required', 'date_format:Y-m-d H:i:s', 'before_or_equal:' . now('Asia/Shanghai')->toDateTimeString()], 'currency' => ['required', Rule::in([(new ReportSync())->currency()])], 'evidence' => ['required', 'string', 'max:1000']] + $this->attributionRules(); $data = Validator::make($input, $rules)->validate(); if (trim($data['evidence']) === '') throw ValidationException::withMessages(['evidence' => '请填写凭据或核对依据']); return DB::transaction(function () use ($data) { $event = DB::table('reporting_events')->where('id', $data['event_id'])->lockForUpdate()->first(); if (!$event || !in_array($event->kind, ['deposit', 'withdraw', 'agent_withdraw'], true)) throw ValidationException::withMessages(['event_id' => '只能核对真实收付款记录']); $before = (array) DB::table('reporting_reconciliations')->where('event_id', $event->id)->first(); $values = array_diff_key($data, array_flip(['agent_id', 'inviter_id', 'client_platform'])) + ['operator_id' => (int) (request()->user->id ?? 0), 'updated_at' => now()->toDateTimeString()]; if (!$before) $values['created_at'] = now()->toDateTimeString(); DB::table('reporting_reconciliations')->updateOrInsert(['event_id' => $event->id], $values); $attribution = $this->verifyAttribution($event->source, $event->source_id, (array) $event, $data, $event->candidate_at); if ($attribution) DB::table('reporting_events')->where('id', $event->id)->update($attribution); $after = (array) DB::table('reporting_reconciliations')->where('event_id', $event->id)->first(); OperationAuditService::record('reporting_payment', (int) $event->id, $before ? 'update' : 'create', $before + ['event_attribution' => (array) $event], $after + ['event_attribution' => $attribution]); return $after; }); } public function reconcileOrder(array $input): array { $data = Validator::make($input, ['id' => ['required', 'integer', 'min:1'], 'settled_at' => ['sometimes', 'required', 'date_format:Y-m-d H:i:s', 'before_or_equal:' . now('Asia/Shanghai')->toDateTimeString()], 'evidence' => ['required', 'string', 'max:1000']] + $this->attributionRules())->validate(); if (trim($data['evidence']) === '') throw ValidationException::withMessages(['evidence' => '请填写核对依据']); return DB::transaction(function () use ($data) { $order = DB::table('reporting_orders')->where('id', $data['id'])->lockForUpdate()->first(); if (!$order || (isset($data['settled_at']) && ((!$order->settled && !$order->voided) || $data['settled_at'] < $order->bet_at))) { throw ValidationException::withMessages(['id' => '已结算或撤销订单才能补录有效的结算时间']); } $before = (array) $order; $attribution = $this->verifyAttribution($order->parent_source, $order->parent_id, (array) $order, $data, $order->bet_at); if (!isset($data['settled_at']) && !$attribution) throw ValidationException::withMessages(['id' => '请提供核对时间或归属信息']); if ($attribution) DB::table('reporting_orders')->where('parent_source', $order->parent_source)->where('parent_id', $order->parent_id)->update($attribution); if (isset($data['settled_at'])) { $context = DB::table('reporting_contexts')->where('source', $order->parent_source)->where('source_id', $order->parent_id)->first(); if (!$context) DB::table('reporting_contexts')->insert(['source' => $order->parent_source, 'source_id' => $order->parent_id, 'user_id' => $order->user_id, 'agent_id' => $order->agent_id, 'inviter_id' => $order->inviter_id, 'client_platform' => $order->client_platform, 'attribution_quality' => $order->attribution_quality, 'settlement_verified' => true, 'occurred_at' => $order->bet_at, 'succeeded_at' => $data['settled_at']]); else DB::table('reporting_contexts')->where('id', $context->id)->update(['succeeded_at' => $data['settled_at'], 'settlement_verified' => true]); DB::table('reporting_orders')->where('parent_source', $order->parent_source)->where('parent_id', $order->parent_id) ->where(function ($q) { $q->where('settled', 1)->orWhere('voided', 1); }) ->update(['settled_at' => $data['settled_at'], 'time_quality' => 'confirmed', 'updated_at' => now()]); } OperationAuditService::record('reporting_order_time', (int) $order->id, 'update', $before, $data); return (array) DB::table('reporting_orders')->where('id', $order->id)->first(); }); } public function quality(array $input): array { $p = Validator::make($input, ['page' => ['integer', 'min:1', 'max:1000000'], 'limit' => ['integer', 'min:1', 'max:100']])->validate(); return ['total' => DB::table('reporting_errors')->count(), 'data' => DB::table('reporting_errors')->orderByDesc('id') ->forPage((int) ($p['page'] ?? 1), (int) ($p['limit'] ?? 20))->get()->map(fn ($r) => (array) $r)->all(), 'sources' => DB::table('reporting_sync_state')->orderBy('source')->get()->map(fn ($r) => ['source' => $r->source, 'state' => json_decode($r->state, true), 'updated_at' => $r->updated_at])->all()]; } private function attributionRules(): array { return ['agent_id' => ['sometimes', 'nullable', 'integer', 'exists:agents,id'], 'inviter_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'], 'client_platform' => ['sometimes', 'required', Rule::in(['H5', 'PC', 'APP', 'unknown'])]]; } private function verifyAttribution(string $source, string $sourceId, array $row, array $data, string $at): array { $values = array_intersect_key($data, array_flip(['agent_id', 'inviter_id', 'client_platform'])); if (!$values) return []; if (empty($row['user_id']) && (array_key_exists('agent_id', $values) || array_key_exists('inviter_id', $values))) { throw ValidationException::withMessages(['agent_id' => '不能改写代理账户实际收付款接收人']); } if (isset($values['inviter_id']) && (int) $values['inviter_id'] === (int) $row['user_id']) { throw ValidationException::withMessages(['inviter_id' => '不能将会员自己设为邀请人']); } $context = (array) DB::table('reporting_contexts')->where('source', $source)->where('source_id', $sourceId)->lockForUpdate()->first(); if (!$context) $context = ['source' => $source, 'source_id' => $sourceId, 'user_id' => $row['user_id'], 'agent_id' => $row['agent_id'], 'inviter_id' => $row['inviter_id'], 'client_platform' => $row['client_platform'], 'occurred_at' => $at, 'succeeded_at' => null]; $context = array_replace($context, $values); $context['attribution_quality'] = array_key_exists('agent_id', $values) ? 'verified' : ($context['attribution_quality'] ?? $row['attribution_quality']); unset($context['id']); DB::table('reporting_contexts')->updateOrInsert(['source' => $source, 'source_id' => $sourceId], $context); return $values + ['attribution_quality' => $context['attribution_quality']]; } }