doge 2 дней назад
Родитель
Сommit
0b336afa43

+ 17 - 8
app/Http/Controllers/admin/Agent.php

@@ -9,6 +9,7 @@ use App\Models\Agent\AgentWithdrawal;
 use App\Models\Agent\AgentRebateRecord;
 use App\Models\EgameItem;
 use App\Services\Agent\AgentDomainService;
+use App\Services\Agent\AgentEquityAllocation;
 use App\Services\Agent\AgentReportService;
 use App\Services\Agent\AgentService;
 use App\Services\Agent\AgentWalletService;
@@ -90,9 +91,7 @@ class Agent extends AgentApiController
     {
         return $this->execute(function () use ($request, $service) {
             $data = $request->validate(array_merge(['id' => ['required', 'integer', 'exists:agents,id']], $this->rules(true)));
-            return DB::transaction(function () use ($service, $data) {
-                return $service->update(AgentModel::query()->findOrFail($data['id']), $data);
-            });
+            return $service->update(AgentModel::query()->findOrFail($data['id']), $data);
         });
     }
 
@@ -303,16 +302,26 @@ class Agent extends AgentApiController
                 'equity_price_per_percent' => ['required', 'numeric', 'min:0', new DecimalNumber()],
                 'equity_min_percent' => ['required', 'numeric', 'gt:0', 'max:100', new DecimalNumber(3, 4)],
                 'equity_max_percent' => ['required', 'numeric', 'gte:equity_min_percent', 'max:100', new DecimalNumber(3, 4)],
+                'company_equity_percent' => ['sometimes', 'required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'equity_recharge_lock_days' => ['nullable', 'integer', 'between:0,365'],
                 'recharge_channel_group_id' => ['nullable', 'integer', \Illuminate\Validation\Rule::exists('recharge_channel_group', 'id')->whereNull('deleted_at')],
             ]);
             if (array_key_exists('recharge_channel_group_id', $data)) $data['recharge_channel_group_id'] = (int) ($data['recharge_channel_group_id'] ?: 1);
             if (array_key_exists('equity_recharge_lock_days', $data) && $data['equity_recharge_lock_days'] === null) unset($data['equity_recharge_lock_days']);
-            $setting = AgentSetting::current();
-            $setting->fill($data + ['updated_by' => (int) request()->user->id]);
-            $setting->equity_enabled = $request->boolean('equity_enabled') ? 1 : 0;
-            $setting->save();
-            return $setting->fresh();
+            return DB::transaction(function () use ($data, $request) {
+                $setting = AgentSetting::lockCurrent();
+                if (isset($data['company_equity_percent'])) {
+                    $companyPercent = (string) $data['company_equity_percent'];
+                    if (bccomp($companyPercent, $setting->company_equity_percent, 4) !== 0
+                        && bccomp(AgentEquityAllocation::available($companyPercent), '0', 4) < 0) {
+                        throw new \InvalidArgumentException('公司持股、已开通股份和待付款股份合计不能超过100%');
+                    }
+                }
+                $setting->fill($data + ['updated_by' => (int) request()->user->id]);
+                $setting->equity_enabled = $request->boolean('equity_enabled') ? 1 : 0;
+                $setting->save();
+                return $setting->fresh();
+            });
         });
     }
 

+ 38 - 0
app/Http/Controllers/agent/ProfileController.php

@@ -5,7 +5,10 @@ namespace App\Http\Controllers\agent;
 use App\Http\Controllers\AgentApiController;
 use App\Models\Agent\AgentLoginLog;
 use App\Services\Agent\AgentDomainService;
+use App\Services\Agent\AgentEquityAllocation;
+use App\Services\Agent\AgentPeriodReportService;
 use App\Services\Agent\AgentReportService;
+use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Hash;
 use App\Models\Agent\AgentToken;
@@ -99,4 +102,39 @@ class ProfileController extends AgentApiController
             return $reports->dashboard($this->currentAgent(), $start, $end);
         });
     }
+
+    public function dashboardCharts(Request $request, AgentPeriodReportService $periods, AgentReportService $reports)
+    {
+        return $this->execute(function () use ($request, $periods, $reports) {
+            $data = $request->validate([
+                'period' => ['nullable', 'in:' . implode(',', AgentPeriodReportService::PERIODS)],
+                'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d'],
+            ]);
+            $today = now()->startOfDay();
+            $end = Carbon::parse($data['end_date'] ?? $today->toDateString())->startOfDay();
+            $start = Carbon::parse($data['start_date'] ?? $end->copy()->subDays(29)->toDateString())->startOfDay();
+            if ($start->gt($end)) {
+                throw new \InvalidArgumentException('开始日期不能大于结束日期');
+            }
+            if ($end->gt($today)) {
+                throw new \InvalidArgumentException('不能查询未来日期');
+            }
+            if ($start->diffInDays($end) >= 366) {
+                throw new \InvalidArgumentException('单次查询不能超过366天');
+            }
+
+            $agent = $this->currentAgent();
+            $period = $data['period'] ?? 'day';
+            return [
+                'scope' => $agent->isEquity() ? 'platform' : 'own',
+                'period' => $period,
+                'start_date' => $start->toDateString(),
+                'end_date' => $end->toDateString(),
+                'registrations' => $periods->registrationTrend($agent, $period, $start, $end),
+                'daily_deposits' => $reports->dailyDeposits($agent, $start, $end),
+                'equity' => AgentEquityAllocation::distribution($agent),
+            ];
+        });
+    }
 }

+ 8 - 1
app/Models/Agent/AgentSetting.php

@@ -4,7 +4,7 @@ namespace App\Models\Agent;
 
 use App\Models\BaseModel;
 
-/** 代理全局配置(单行 id=1):停止分成阈值、入股价格与区间、入股充值锁定天数、充值通道组合。 */
+/** 代理全局配置(单行 id=1):停止分成阈值、入股、公司持股、入股充值锁定天数、充值通道组合。 */
 class AgentSetting extends BaseModel
 {
     protected $table = 'agent_settings';
@@ -13,6 +13,7 @@ class AgentSetting extends BaseModel
     protected $casts = [
         'margin_stop_percent' => 'decimal:4', 'equity_enabled' => 'integer',
         'equity_price_per_percent' => 'decimal:4', 'equity_min_percent' => 'decimal:4',
+        'company_equity_percent' => 'decimal:4',
         'equity_max_percent' => 'decimal:4', 'updated_by' => 'integer',
         'recharge_channel_group_id' => 'integer', 'equity_recharge_lock_days' => 'integer',
     ];
@@ -21,4 +22,10 @@ class AgentSetting extends BaseModel
     {
         return static::query()->firstOrCreate(['id' => 1]);
     }
+
+    /** 在事务中调用,以串行处理公司持股调整和股份分配。 */
+    public static function lockCurrent(): self
+    {
+        return static::query()->lockForUpdate()->findOrFail(1);
+    }
 }

+ 60 - 0
app/Services/Agent/AgentEquityAllocation.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentEquityOrder;
+use App\Models\Agent\AgentSetting;
+use Illuminate\Database\Eloquent\Builder;
+
+class AgentEquityAllocation
+{
+    private const TOTAL_PERCENT = '100.0000';
+
+    private static function shareholders(): Builder
+    {
+        return Agent::query()->where('type', Agent::TYPE_EQUITY)->where('is_test', 0);
+    }
+
+    /** 业务额度预留待付款订单;扣款时再校验实际可分配股份,兼容历史待付款订单。 */
+    public static function available(string $companyPercent, bool $reservePending = true): string
+    {
+        $held = (string) self::shareholders()->sum('equity_percent');
+        $available = bcsub(bcsub(self::TOTAL_PERCENT, $companyPercent, 4), $held, 4);
+        if ($reservePending) {
+            $pending = AgentEquityOrder::query()->where('status', AgentEquityOrder::STATUS_PENDING)
+                ->whereIn('agent_id', self::shareholders()->select('id'))->sum('percent');
+            $available = bcsub($available, (string) $pending, 4);
+        }
+        return $available;
+    }
+
+    public static function distribution(Agent $agent): ?array
+    {
+        if (!$agent->isEquity()) {
+            return null;
+        }
+
+        // 在同一条查询里读取公司和股东的持股,避免拼接不同时间的持股数据。
+        $holdings = self::shareholders();
+        $shares = AgentSetting::query()->whereKey(1)->select('company_equity_percent')
+            ->selectSub((clone $holdings)->whereKey($agent->id)->selectRaw('COALESCE(SUM(equity_percent), 0)'), 'own_percent')
+            ->selectSub($holdings->whereKeyNot($agent->id)->selectRaw('COALESCE(SUM(equity_percent), 0)'), 'other_percent')
+            ->firstOrFail();
+        $own = bcadd((string) $shares->own_percent, '0', 4);
+        $other = bcadd((string) $shares->other_percent, '0', 4);
+        $company = $shares->company_equity_percent;
+        $remaining = bcsub(bcsub(bcsub(self::TOTAL_PERCENT, $own, 4), $other, 4), $company, 4);
+
+        // 历史持股超额时没有有效的饼图数据。
+        if (bccomp($remaining, '0', 4) < 0) {
+            return null;
+        }
+        return [
+            'own_percent' => $own,
+            'other_percent' => $other,
+            'company_percent' => $company,
+            'remaining_percent' => $remaining,
+        ];
+    }
+}

+ 14 - 1
app/Services/Agent/AgentEquityService.php

@@ -25,6 +25,12 @@ class AgentEquityService
         $pending = AgentEquityOrder::query()->where('agent_id', $agent->id)->where('status', AgentEquityOrder::STATUS_PENDING)->first();
         $held = bcadd((string) $agent->equity_percent, '0', 4);
         $remaining = bcsub((string) $setting->equity_max_percent, $held, 4);
+        if ($agent->isEquity() && !$agent->is_test) {
+            $available = AgentEquityAllocation::available($setting->company_equity_percent);
+            if (bccomp($available, $remaining, 4) < 0) {
+                $remaining = $available;
+            }
+        }
         return [
             'enabled' => (int) $setting->equity_enabled,
             'price_per_percent' => (string) $setting->equity_price_per_percent,
@@ -42,7 +48,7 @@ class AgentEquityService
         $order = DB::transaction(function () use ($agent, $percent) {
             $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
             if (!$agent->isEquity()) throw new \RuntimeException('只有入股代理可以购买股份');
-            $setting = AgentSetting::current();
+            $setting = AgentSetting::lockCurrent();
             if (!(int) $setting->equity_enabled || bccomp((string) $setting->equity_price_per_percent, '0', 4) <= 0) {
                 throw new \RuntimeException('入股暂未开放');
             }
@@ -55,6 +61,9 @@ class AgentEquityService
             if (bccomp(bcadd((string) $agent->equity_percent, $percent, 4), (string) $setting->equity_max_percent, 4) > 0) {
                 throw new \RuntimeException("累计持股不能超过 {$setting->equity_max_percent}%");
             }
+            if (!$agent->is_test && bccomp($percent, AgentEquityAllocation::available($setting->company_equity_percent), 4) > 0) {
+                throw new \RuntimeException('可购买股份不足');
+            }
             return AgentEquityOrder::query()->create([
                 'order_no' => 'AE' . now()->format('YmdHis') . strtoupper(Str::random(6)),
                 'agent_id' => $agent->id, 'percent' => bcadd($percent, '0', 4),
@@ -142,8 +151,11 @@ class AgentEquityService
             $done = 0;
             $orders = AgentEquityOrder::query()->where('agent_id', $agentId)->where('status', AgentEquityOrder::STATUS_PENDING)
                 ->orderBy('id')->lockForUpdate()->get();
+            if ($orders->isEmpty()) return 0;
+            $available = $agent->is_test ? null : AgentEquityAllocation::available(AgentSetting::lockCurrent()->company_equity_percent, false);
             foreach ($orders as $order) {
                 if (bccomp((string) $agent->balance, (string) $order->amount, 4) < 0) break;
+                if ($available !== null && bccomp($order->percent, $available, 4) > 0) break;
                 $this->wallets->adjust($agentId, (string) $order->amount, 'equity_purchase', '购买股份 ' . $order->percent . '%',
                     'equity-order:' . $order->id, 'agent', $agentId, 'agent_equity_order', (int) $order->id);
                 $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
@@ -152,6 +164,7 @@ class AgentEquityService
                 $order->status = AgentEquityOrder::STATUS_COMPLETED;
                 $order->completed_at = now();
                 $order->save();
+                if ($available !== null) $available = bcsub($available, $order->percent, 4);
                 ++$done;
             }
             return $done;

+ 25 - 9
app/Services/Agent/AgentPeriodReportService.php

@@ -34,7 +34,7 @@ class AgentPeriodReportService
         $from = $start->toDateString();
         $to = $end->toDateString();
 
-        $buckets = $this->buckets($period, $start, $end);
+        $buckets = $this->registrationBuckets($agent, $period, $start, $end);
         $bucketOf = fn (string $date) => $this->key($period, Carbon::parse($date));
 
         // 资金 / 注单:先按天汇总,再归到周期
@@ -60,14 +60,6 @@ class AgentPeriodReportService
             }
         }
 
-        // 新增会员(按注册时间)
-        foreach ($this->members($agent)->whereBetween('created_at', [$from . ' 00:00:00', $to . ' 23:59:59'])
-            ->groupByRaw('DATE(' . $g->wrap('created_at') . ')')
-            ->selectRaw('DATE(' . $g->wrap('created_at') . ') AS d, COUNT(*) AS c')->get() as $row) {
-            $key = $bucketOf(substr((string) $row->d, 0, 10));
-            if (isset($buckets[$key])) $buckets[$key]['new_members'] += (int) $row->c;
-        }
-
         // 游戏分类(来源同会员日报:已结算、未作废的注单,按结算时间)
         $categoryNames = ShareDefaults::categories();
         $totalCategories = [];
@@ -113,6 +105,30 @@ class AgentPeriodReportService
         ];
     }
 
+    public function registrationTrend(Agent $agent, string $period, Carbon $start, Carbon $end): array
+    {
+        $fields = array_flip(['period_key', 'label', 'start_date', 'end_date', 'new_members']);
+        return array_values(array_map(
+            fn (array $bucket) => array_intersect_key($bucket, $fields),
+            $this->registrationBuckets($agent, $period, $start, $end)
+        ));
+    }
+
+    private function registrationBuckets(Agent $agent, string $period, Carbon $start, Carbon $end): array
+    {
+        $buckets = $this->buckets($period, $start, $end);
+        $date = 'DATE(' . DB::getQueryGrammar()->wrap('created_at') . ')';
+        $daily = $this->members($agent)
+            ->where('created_at', '>=', $start)
+            ->where('created_at', '<', $end->copy()->addDay())
+            ->selectRaw("{$date} AS d, COUNT(*) AS c")->groupByRaw($date)->get();
+        foreach ($daily as $row) {
+            $key = $this->key($period, Carbon::parse($row->d));
+            $buckets[$key]['new_members'] += (int) $row->c;
+        }
+        return $buckets;
+    }
+
     /** 统计范围内的会员(合营:名下;入股:全平台非试玩)。 */
     private function members(Agent $agent)
     {

+ 50 - 9
app/Services/Agent/AgentReportService.php

@@ -6,6 +6,7 @@ use App\Models\Agent\Agent;
 use App\Models\Agent\AgentDailyStat;
 use App\Models\Agent\AgentRebateRecord;
 use App\Models\User;
+use App\Support\StatsScope;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
@@ -28,12 +29,54 @@ class AgentReportService
     {
         if ($agent->isEquity()) {
             // 全平台:排除试玩账号和测试会员(测试代理名下)
-            $members = \App\Support\StatsScope::realUsers(User::query(), 'users');
+            $members = StatsScope::realUsers(User::query(), 'users');
             return $this->aggregate($members, 0, [], $startDate, $endDate) + ['scope' => 'platform'];
         }
         return $this->summaryForTree($agent, $startDate, $endDate) + ['scope' => 'own'];
     }
 
+    public function dailyDeposits(Agent $agent, Carbon $start, Carbon $end): array
+    {
+        $members = $agent->isEquity()
+            ? StatsScope::realUsers(User::query(), 'users')
+            : User::query()->where('agent_id', $agent->id);
+        $deposits = $this->depositSources($members->select('member_id'), $start, $end, Schema::hasTable('payment_orders'));
+        $orders = array_shift($deposits)->select('member_id', 'created_at')
+            ->selectRaw('amount * COALESCE(exchange_rate, 1) AS amount');
+        foreach ($deposits as $source) {
+            $orders->unionAll($source->select('member_id', 'created_at', 'amount'));
+        }
+        $date = 'DATE(' . DB::getQueryGrammar()->wrap('created_at') . ')';
+        $daily = DB::query()->fromSub($orders, 'deposits')->groupByRaw($date)
+            ->selectRaw("{$date} AS stat_date, COUNT(DISTINCT member_id) AS deposit_members, SUM(amount) AS deposit_amount")
+            ->get()->keyBy('stat_date');
+
+        $list = [];
+        for ($day = $start->copy(); $day->lte($end); $day->addDay()) {
+            $date = $day->toDateString();
+            $row = $daily->get($date);
+            $list[] = [
+                'date' => $date,
+                'deposit_members' => (int) ($row->deposit_members ?? 0),
+                'deposit_amount' => $this->decimal($row->deposit_amount ?? 0),
+            ];
+        }
+        return $list;
+    }
+
+    private function depositSources($memberIds, Carbon $start, Carbon $end, bool $hasPaymentOrders): array
+    {
+        $endExclusive = $end->copy()->startOfDay()->addDay();
+        $inRange = fn (string $table) => DB::table($table)->whereIn('member_id', clone $memberIds)
+            ->where('created_at', '>=', $start)->where('created_at', '<', $endExclusive);
+        $sources = [$inRange('recharges')->where('status', 1)];
+        if ($hasPaymentOrders) {
+            $sources[] = $inRange('payment_orders')->where('status', PaymentOrderService::STATUS_SUCCESS)
+                ->whereIn('type', [PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY]);
+        }
+        return $sources;
+    }
+
     public function summaryForTree(Agent $root, string $startDate, string $endDate): array
     {
         $ids = $this->visibleAgentIds($root);
@@ -77,16 +120,14 @@ class AgentReportService
         $memberCount = (clone $members)->count();
         $memberIds = (clone $members)->select('member_id');
         $memberBalance = DB::table('wallets')->whereIn('member_id', clone $memberIds)->sum('available_balance');
-        $depositAmount = DB::table('recharges')->whereIn('member_id', clone $memberIds)
-            ->where('status', 1)->whereBetween('created_at', [$start, $end])->sum('amount');
+        $hasPaymentOrders = Schema::hasTable('payment_orders');
+        $depositAmount = '0.0000';
+        foreach ($this->depositSources($memberIds, $start, $end, $hasPaymentOrders) as $source) {
+            $depositAmount = bcadd($depositAmount, (string) $source->sum('amount'), 4);
+        }
         $withdrawAmount = DB::table('withdraws')->whereIn('member_id', clone $memberIds)
             ->where('status', 1)->whereBetween('created_at', [$start, $end])->sum('amount');
-        if (Schema::hasTable('payment_orders')) {
-            $depositAmount = bcadd((string) $depositAmount, (string) DB::table('payment_orders')
-                ->whereIn('member_id', clone $memberIds)
-                ->where('status', PaymentOrderService::STATUS_SUCCESS)
-                ->whereIn('type', [PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY])
-                ->whereBetween('created_at', [$start, $end])->sum('amount'), 4);
+        if ($hasPaymentOrders) {
             $withdrawAmount = bcadd((string) $withdrawAmount, (string) DB::table('payment_orders')
                 ->whereIn('member_id', clone $memberIds)
                 ->where('status', PaymentOrderService::STATUS_SUCCESS)

+ 10 - 0
app/Services/Agent/AgentService.php

@@ -5,6 +5,7 @@ namespace App\Services\Agent;
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentApplication;
 use App\Models\Agent\AgentEquityOrder;
+use App\Models\Agent\AgentSetting;
 use App\Models\Agent\AgentToken;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
@@ -59,6 +60,15 @@ class AgentService
     {
         return DB::transaction(function () use ($agent, $data): Agent {
             $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            if ($agent->isEquity() && $agent->is_test && isset($data['is_test']) && (int) $data['is_test'] === 0) {
+                $setting = AgentSetting::lockCurrent();
+                $pending = AgentEquityOrder::query()->where('agent_id', $agent->id)
+                    ->where('status', AgentEquityOrder::STATUS_PENDING)->sum('percent');
+                $added = bcadd($agent->equity_percent, (string) $pending, 4);
+                if (bccomp($added, '0', 4) > 0 && bccomp($added, AgentEquityAllocation::available($setting->company_equity_percent), 4) > 0) {
+                    throw new \RuntimeException('剩余股份不足,无法转为正式代理');
+                }
+            }
             if (array_key_exists('type', $data) && $data['type'] !== null && (int) $data['type'] !== (int) $agent->type) {
                 $this->assertTypeChangeable($agent);
                 $agent->type = (int) $data['type'];

+ 20 - 0
database/migrations/2026_11_11_120000_add_company_equity_percent_to_agent_settings.php

@@ -0,0 +1,20 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('agent_settings', function (Blueprint $table) {
+            $table->decimal('company_equity_percent', 7, 4)->default(0);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('agent_settings', fn (Blueprint $table) => $table->dropColumn('company_equity_percent'));
+    }
+};

+ 1 - 1
docs/代理/代理后台调整-前端接入.md

@@ -211,7 +211,7 @@
 ```
 
 - 应付金额 = 购买比例 × `price_per_percent`。
-- `remaining_percent` 是还能买的比例(累计不超过 `max_percent`)。
+- `remaining_percent` 是还能买的比例,受个人持股上限和平台可购买股份限制。
 - `pending_order` 是待付款的订单,页面上要提示「充值到账后自动开通」。
 
 ### `POST /agent/equity/orders`

+ 68 - 0
docs/代理/独立代理首页图表-前端接入.md

@@ -0,0 +1,68 @@
+# 独立代理首页图表接入
+
+接口沿用各自后台的登录态。代理后台请求头使用 `Authorization: Bearer <agent_token>`。成功时 `code` 为 `0`,结果在 `data`。失败原因见 `msg`。
+
+## `GET /agent/dashboard/charts`
+
+| 查询参数 | 取值 | 默认值 |
+|---|---|---|
+| `period` | `day` 按日、`week` 按周、`month` 按月 | `day` |
+| `start_date` | `YYYY-MM-DD` | 结束日期往前 29 天 |
+| `end_date` | `YYYY-MM-DD` | 当天 |
+
+起止日期均计入,最多查询 366 个自然日,结束日期最晚为当天。周为周一至周日,月为自然月,首尾周期按查询范围截取。两个序列都按日期升序返回,空档补零。
+
+```http
+GET /agent/dashboard/charts?period=week&start_date=2026-11-09&end_date=2026-11-10
+```
+
+响应示例中的 `data` 如下。
+
+```json
+{
+  "scope": "platform",
+  "period": "week",
+  "start_date": "2026-11-09",
+  "end_date": "2026-11-10",
+  "registrations": [
+    {"period_key": "2026-W46", "label": "11-09 ~ 11-10", "start_date": "2026-11-09", "end_date": "2026-11-10", "new_members": 24}
+  ],
+  "daily_deposits": [
+    {"date": "2026-11-09", "deposit_members": 12, "deposit_amount": "3500.5000"},
+    {"date": "2026-11-10", "deposit_members": 0, "deposit_amount": "0.0000"}
+  ],
+  "equity": {
+    "own_percent": "2.5000",
+    "other_percent": "17.5000",
+    "company_percent": "60.0000",
+    "remaining_percent": "20.0000"
+  }
+}
+```
+
+| 字段 | 含义 |
+|---|---|
+| `scope` | `own` 为合营代理名下会员,`platform` 为入股代理查看的全平台会员,排除试玩、测试和模拟会员 |
+| `registrations[].new_members` | 周期内按注册日期统计的新注册人数,整数。`period_key` 分别为 `YYYY-MM-DD`、`YYYY-Www`、`YYYY-MM` |
+| `daily_deposits[].deposit_members` | 当日成功充值的去重会员数,整数。同一会员同日跨渠道只计一次 |
+| `daily_deposits[].deposit_amount` | 当日成功充值总额,人民币,四位小数字符串。包含 USDT、在线和人工充值订单。USDT 按订单汇率折算,缺少汇率的历史订单使用原金额 |
+| `equity` | 当前持股分布,与查询日期无关。`own_percent` 为当前股东持有,`other_percent` 为其他正式股东已开通份额,`company_percent` 为公司持股,`remaining_percent` 为剩余份额。四项合计 `100.0000`,单位为 `%`。测试股份和待付款订单不计入。合营代理或历史持股合计超过 100% 时返回 `null` |
+
+充值人数和金额均按成功订单的创建日期统计,固定按日返回。
+
+## `GET /admin/agent/settings` 与 `POST /admin/agent/settings`
+
+读取和保存成功时,`data.company_equity_percent` 返回四位小数字符串,默认 `"0.0000"`。保存时可提交 `0` 至 `100`,最多四位小数。省略该字段时保留当前值。公司持股、已开通股份与待付款订单预留的股份合计最多为 100%。
+
+保存时一并提交当前的其他必填配置。
+
+```json
+{
+  "margin_stop_percent": "10.0000",
+  "equity_enabled": 1,
+  "equity_price_per_percent": "100000.0000",
+  "equity_min_percent": "0.1000",
+  "equity_max_percent": "1.0000",
+  "company_equity_percent": "60.0000"
+}
+```

+ 1 - 0
routes/agent.php

@@ -27,6 +27,7 @@ Route::middleware('agent.auth')->group(function () {
     Route::get('/profile/domains', [ProfileController::class, 'domains']);
     Route::get('/profile/login-logs', [ProfileController::class, 'loginLogs']);
     Route::get('/dashboard', [ProfileController::class, 'dashboard']);
+    Route::get('/dashboard/charts', [ProfileController::class, 'dashboardCharts']);
 
     Route::get('/members', [MemberController::class, 'index']);
     Route::get('/members/finance', [MemberController::class, 'finance']);

+ 80 - 0
tests/Integration/AgentDashboardTest.php

@@ -4,7 +4,10 @@ namespace Tests\Integration;
 
 use App\Http\Controllers\agent\ProfileController;
 use App\Models\Agent\Agent;
+use App\Models\Agent\AgentSetting;
+use App\Services\Agent\AgentPeriodReportService;
 use App\Services\Agent\AgentReportService;
+use App\Services\PaymentOrderService;
 use Illuminate\Http\Request;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Support\Facades\DB;
@@ -82,4 +85,81 @@ class AgentDashboardTest extends TestCase
         $rows = array_column((new AgentReportService())->reportRows(null, ['start_date' => '2026-09-07'])['list'], null, 'id');
         $this->assertSame(0, $rows[$equity->id]['member_count'], 'admin agent report still counts only the equity agent\'s own members');
     }
+
+    public function test_charts_group_registrations_and_deduplicate_successful_daily_deposits_across_channels(): void
+    {
+        $agent = $this->agent();
+        $other = $this->agent();
+        foreach ([['a', $agent, '2026-08-31'], ['b', $agent, '2026-09-02'], ['c', $other, '2026-09-02']] as [$id, $owner, $day]) {
+            DB::table('users')->insert(['member_id' => $id, 'username' => $id, 'agent_id' => $owner->id, 'created_at' => $day . ' 00:00:00']);
+        }
+        DB::table('recharges')->insert(['member_id' => 'a', 'status' => 1, 'amount' => 100,
+            'exchange_rate' => '7.1250', 'created_at' => '2026-09-02 10:00:00']);
+        DB::table('recharges')->insert(['member_id' => 'a', 'status' => 1, 'amount' => 20, 'created_at' => '2026-09-03 00:00:00']);
+        foreach ([
+            ['a', PaymentOrderService::TYPE_PAY, PaymentOrderService::STATUS_SUCCESS, 30],
+            ['b', PaymentOrderService::TYPE_SELF_PAY, PaymentOrderService::STATUS_SUCCESS, 40],
+            ['c', PaymentOrderService::TYPE_PAY, PaymentOrderService::STATUS_SUCCESS, 1000],
+            ['a', PaymentOrderService::TYPE_PAYOUT, PaymentOrderService::STATUS_SUCCESS, 1000],
+            ['a', PaymentOrderService::TYPE_PAY, PaymentOrderService::STATUS_STAY, 1000],
+        ] as [$member, $type, $status, $amount]) {
+            DB::table('payment_orders')->insert(['member_id' => $member, 'type' => $type, 'status' => $status,
+                'amount' => $amount, 'created_at' => '2026-09-02 23:59:59']);
+        }
+
+        $range = ['start_date' => '2026-08-31', 'end_date' => '2026-09-07'];
+        $days = $this->charts($agent, $range);
+        $this->assertSame('own', $days['scope']);
+        $this->assertNull($days['equity']);
+        $this->assertSame([1, 0, 1, 0, 0, 0, 0, 0], array_column($days['registrations'], 'new_members'));
+        $this->assertSame([0, 0, 2, 1, 0, 0, 0, 0], array_column($days['daily_deposits'], 'deposit_members'));
+        $this->assertSame(['0.0000', '0.0000', '782.5000', '20.0000', '0.0000', '0.0000', '0.0000', '0.0000'], array_column($days['daily_deposits'], 'deposit_amount'));
+
+        $weeks = $this->charts($agent, $range + ['period' => 'week']);
+        $this->assertSame(['2026-W36', '2026-W37'], array_column($weeks['registrations'], 'period_key'));
+        $this->assertSame([2, 0], array_column($weeks['registrations'], 'new_members'));
+        $this->assertSame($days['daily_deposits'], $weeks['daily_deposits']);
+        $months = $this->charts($agent, $range + ['period' => 'month']);
+        $this->assertSame(['2026-08', '2026-09'], array_column($months['registrations'], 'period_key'));
+        $this->assertSame([1, 1], array_column($months['registrations'], 'new_members'));
+    }
+
+    public function test_shareholder_chart_shows_current_formal_holdings_and_real_platform_members(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->integer('from')->default(1);
+            $table->integer('user_type')->default(0);
+        });
+        $holder = $this->agent(Agent::TYPE_EQUITY);
+        $other = $this->agent(Agent::TYPE_EQUITY);
+        $test = $this->agents->create(['username' => 'test-holder', 'password' => 'secret1', 'type' => Agent::TYPE_EQUITY, 'is_test' => 1]);
+        foreach ([[$holder, '12.3456'], [$other, '7.6544'], [$test, '9.5000']] as [$shareholder, $percent]) {
+            Agent::query()->whereKey($shareholder->id)->update(['equity_percent' => $percent]);
+        }
+        AgentSetting::current()->update(['company_equity_percent' => '60.5000']);
+        foreach ([['real', null, 1, 0], ['test', $test->id, 1, 0], ['guest', null, 2, 1], ['simulated', null, 1, 2]] as [$id, $agentId, $from, $type]) {
+            DB::table('users')->insert(['member_id' => $id, 'username' => $id, 'agent_id' => $agentId,
+                'from' => $from, 'user_type' => $type, 'created_at' => now()]);
+            DB::table('recharges')->insert(['member_id' => $id, 'status' => 1, 'amount' => 100, 'created_at' => now()]);
+        }
+
+        $charts = $this->charts($holder, ['start_date' => '2026-09-07']);
+        $this->assertSame('platform', $charts['scope']);
+        $this->assertSame([1], array_column($charts['registrations'], 'new_members'));
+        $this->assertSame([1], array_column($charts['daily_deposits'], 'deposit_members'));
+        $this->assertSame(['100.0000'], array_column($charts['daily_deposits'], 'deposit_amount'));
+        $this->assertSame([
+            'own_percent' => '12.3456', 'other_percent' => '7.6544', 'company_percent' => '60.5000', 'remaining_percent' => '19.5000',
+        ], $charts['equity']);
+    }
+
+    private function charts(Agent $agent, array $params): array
+    {
+        $request = Request::create('/agent/dashboard/charts', 'GET', $params);
+        $request->attributes->set('agent', $agent);
+        $this->app->instance('request', $request);
+        $response = (new ProfileController())->dashboardCharts($request, new AgentPeriodReportService(), new AgentReportService())->getData(true);
+        $this->assertSame(0, $response['code'], (string) $response['msg']);
+        return $response['data'];
+    }
 }

+ 55 - 0
tests/Integration/AgentEquityOrderAdminTest.php

@@ -62,4 +62,59 @@ class AgentEquityOrderAdminTest extends TestCase
         $own = $equity->order($poor, '0.2');
         $this->assertNull($equity->cancel($poor, $own->id)->cancelled_by, 'agent self-cancel has no admin');
     }
+
+    public function test_company_equity_setting_accounts_for_completed_and_pending_formal_purchases(): void
+    {
+        $admin = new AdminAgent();
+        $this->assertSame('0.0000', $admin->settings()->getData(true)['data']['company_equity_percent']);
+        $saved = $this->saveSettings(['company_equity_percent' => '98.5000']);
+        $this->assertSame(0, $saved['code'], $saved['msg']);
+        $this->assertSame('98.5000', $saved['data']['company_equity_percent']);
+        $this->assertSame('98.5000', $this->saveSettings([])['data']['company_equity_percent']);
+        $this->assertNotSame(0, $this->saveSettings(['company_equity_percent' => '100.0001'])['code']);
+        $this->assertNotSame(0, $this->saveSettings(['company_equity_percent' => '0.00001'])['code']);
+
+        $equity = new AgentEquityService(new AgentWalletService());
+        $holder = $this->agent(Agent::TYPE_EQUITY);
+        $buyer = $this->agent(Agent::TYPE_EQUITY);
+        $another = $this->agent(Agent::TYPE_EQUITY);
+        Agent::query()->whereKey($holder->id)->update(['balance' => 1000, 'realtime_balance' => 1000]);
+        $this->assertSame(AgentEquityOrder::STATUS_COMPLETED, $equity->order($holder, '1')->status);
+        $pending = $equity->order($buyer, '0.5');
+        $this->assertSame(AgentEquityOrder::STATUS_PENDING, $pending->status);
+        $this->assertSame('0.0000', $equity->overview($another)['remaining_percent']);
+        $this->assertNotSame(0, $this->saveSettings(['company_equity_percent' => '98.5001'])['code']);
+        try {
+            $equity->order($another, '0.1');
+            $this->fail('Pending purchases reserve the remaining allocation.');
+        } catch (\RuntimeException $e) {
+            $this->assertSame('可购买股份不足', $e->getMessage());
+        }
+        $equity->cancel($buyer, $pending->id);
+        $this->assertSame('99.0000', $this->saveSettings(['company_equity_percent' => '99'])['data']['company_equity_percent']);
+
+        $legacyOrder = $pending->replicate(['status', 'cancel_remark']);
+        $legacyOrder->forceFill(['order_no' => 'legacy-overbooked', 'status' => AgentEquityOrder::STATUS_PENDING])->save();
+        Agent::query()->whereKey($buyer->id)->update(['balance' => 1000, 'realtime_balance' => 1000]);
+        $this->assertSame(0, $equity->fulfillPending($buyer->id));
+        $this->assertSame('1000.0000', $buyer->fresh()->balance);
+        $this->assertSame(AgentEquityOrder::STATUS_PENDING, $legacyOrder->fresh()->status);
+    }
+
+    private function saveSettings(array $params): array
+    {
+        $request = new class([], $params + [
+            'margin_stop_percent' => 10,
+            'equity_enabled' => 1,
+            'equity_price_per_percent' => 1000,
+            'equity_min_percent' => '0.1',
+            'equity_max_percent' => '1',
+        ]) extends Request {
+            public object $user;
+        };
+        $request->setMethod('POST');
+        $request->user = (object) ['id' => 7];
+        $this->app->instance('request', $request);
+        return (new AdminAgent())->saveSettings($request)->getData(true);
+    }
 }

+ 3 - 1
tests/Support/AgentTestEnvironment.php

@@ -95,11 +95,13 @@ trait AgentTestEnvironment
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_130000_allow_multiple_agent_domains.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_140000_merge_agent_domain_types.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_10_120000_add_is_test_to_agents.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_120000_add_company_equity_percent_to_agent_settings.php')->up();
 
         foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
-            Schema::create($table, function (Blueprint $t) {
+            Schema::create($table, function (Blueprint $t) use ($table) {
                 $t->id(); $t->string('member_id'); $t->integer('status'); $t->string('type')->nullable();
                 $t->decimal('amount', 18, 4); $t->timestamps();
+                if ($table === 'recharges') $t->decimal('exchange_rate', 10, 6)->nullable();
             });
         }
         Schema::create('wallets', function (Blueprint $t) {