doge 1 день назад
Родитель
Сommit
e954529d95

+ 11 - 0
app/Http/Controllers/AgentApiController.php

@@ -31,6 +31,17 @@ abstract class AgentApiController extends Controller
         }
     }
 
+    /** 下载类接口:成功时直接返回文件响应,出错时和 execute() 一样返回 JSON 错误。 */
+    protected function executeFile(Closure $callback): \Symfony\Component\HttpFoundation\Response
+    {
+        $file = null;
+        $error = $this->execute(function () use ($callback, &$file) {
+            $file = $callback();
+            return null;
+        });
+        return $file ?? $error;
+    }
+
     protected function currentAgent(): Agent
     {
         $agent = request()->attributes->get('agent');

+ 63 - 0
app/Http/Controllers/admin/CompanyReport.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\CompanyMonthlyReportService;
+use Illuminate\Http\Request;
+
+/** 总后台「公司营业报表」:按月上传 Excel,列表、新增(上个月及以前未上传的月份)、编辑(重新上传)、下载。 */
+class CompanyReport extends AgentApiController
+{
+    public function index(Request $request, CompanyMonthlyReportService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'start_month' => ['nullable', 'date_format:Y-m'], 'end_month' => ['nullable', 'date_format:Y-m', 'after_or_equal:start_month'],
+            ]);
+            return $service->paginate($data, true);
+        });
+    }
+
+    public function create(Request $request, CompanyMonthlyReportService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'month' => ['required', 'date_format:Y-m'],
+                'file' => self::fileRule(),
+            ], self::messages());
+            return $service->create($data['month'], $request->file('file'), (int) request()->user->id);
+        });
+    }
+
+    public function update(Request $request, CompanyMonthlyReportService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['id' => ['required', 'integer', 'min:1'], 'file' => self::fileRule()], self::messages());
+            return $service->replace((int) $data['id'], $request->file('file'), (int) request()->user->id);
+        });
+    }
+
+    public function download(Request $request, CompanyMonthlyReportService $service)
+    {
+        return $this->executeFile(function () use ($request, $service) {
+            $data = $request->validate(['id' => ['required', 'integer', 'min:1']]);
+            return $service->download((int) $data['id']);
+        });
+    }
+
+    public static function fileRule(): array
+    {
+        return ['required', 'file', 'extensions:' . implode(',', CompanyMonthlyReportService::EXTENSIONS), 'max:' . CompanyMonthlyReportService::MAX_KB];
+    }
+
+    public static function messages(): array
+    {
+        return [
+            'month.required' => '请选择月份', 'month.date_format' => '月份格式为 YYYY-MM',
+            'file.required' => '请上传报表文件', 'file.file' => '请上传报表文件',
+            'file.extensions' => '请上传 Excel 文件(.xlsx 或 .xls)', 'file.max' => '报表文件不能超过 20MB',
+        ];
+    }
+}

+ 37 - 0
app/Http/Controllers/agent/CompanyReportController.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\CompanyMonthlyReportService;
+use Illuminate\Http\Request;
+
+/** 入股代理查看、下载公司营业报表(总后台按月上传)。合营代理不可见。 */
+class CompanyReportController extends AgentApiController
+{
+    private function onlyEquity(): void
+    {
+        if (!$this->currentAgent()->isEquity()) throw new \RuntimeException('仅入股代理可查看公司营业报表');
+    }
+
+    public function index(Request $request, CompanyMonthlyReportService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $this->onlyEquity();
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'start_month' => ['nullable', 'date_format:Y-m'], 'end_month' => ['nullable', 'date_format:Y-m', 'after_or_equal:start_month'],
+            ]);
+            return $service->paginate($data, false);
+        });
+    }
+
+    public function download(Request $request, CompanyMonthlyReportService $service)
+    {
+        return $this->executeFile(function () use ($request, $service) {
+            $this->onlyEquity();
+            $data = $request->validate(['id' => ['required', 'integer', 'min:1']]);
+            return $service->download((int) $data['id']);
+        });
+    }
+}

+ 13 - 0
app/Models/CompanyMonthlyReport.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class CompanyMonthlyReport extends Model
+{
+    protected $table = 'company_monthly_reports';
+    protected $fillable = ['month', 'file_name', 'file_path', 'file_size', 'created_by', 'updated_by'];
+    protected $hidden = ['file_path'];
+    protected $casts = ['file_size' => 'integer', 'created_by' => 'integer', 'updated_by' => 'integer'];
+}

+ 138 - 0
app/Services/CompanyMonthlyReportService.php

@@ -0,0 +1,138 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Admin;
+use App\Models\CompanyMonthlyReport;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+
+/**
+ * 公司营业报表(按月 Excel)。总后台新增 / 重新上传;入股代理只读、下载。
+ * 文件存 local 磁盘(不公开),只能通过登录后的下载接口获取。
+ */
+class CompanyMonthlyReportService
+{
+    public const DISK = 'local';
+    public const DIR = 'company-reports';
+    public const EXTENSIONS = ['xlsx', 'xls'];
+    public const MAX_KB = 20480;
+
+    /** 可以新增的最近月份(上个月)。更早的月份如果漏传,也可以补传;当月和以后的月份不行。 */
+    public static function allowedMonth(): string
+    {
+        return now()->startOfMonth()->subMonthNoOverflow()->format('Y-m');
+    }
+
+    public function paginate(array $params, bool $admin): array
+    {
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $query = CompanyMonthlyReport::query();
+        if (!empty($params['start_month'])) $query->where('month', '>=', $params['start_month']);
+        if (!empty($params['end_month'])) $query->where('month', '<=', $params['end_month']);
+        $total = (clone $query)->count();
+        $list = $query->orderByDesc('month')->forPage($page, $limit)->get();
+        if ($admin) {
+            $ids = $list->pluck('created_by')->merge($list->pluck('updated_by'))->filter()->unique()->all();
+            $names = $ids ? Admin::query()->whereIn('id', $ids)->pluck('username', 'id')->all() : [];
+            $list = $list->map(fn ($row) => $this->format($row) + [
+                'created_by' => $row->created_by, 'created_by_name' => (string) ($names[$row->created_by] ?? ''),
+                'updated_by' => $row->updated_by, 'updated_by_name' => (string) ($names[$row->updated_by] ?? ''),
+            ]);
+        } else {
+            $list = $list->map(fn ($row) => $this->format($row));
+        }
+        $result = compact('total', 'page', 'limit') + ['list' => $list->values()];
+        if ($admin) $result['allowed_month'] = self::allowedMonth();
+        return $result;
+    }
+
+    public function create(string $month, UploadedFile $file, int $adminId): array
+    {
+        if ($month > self::allowedMonth()) {
+            throw new \InvalidArgumentException('只能上传已结束月份的报表(' . self::allowedMonth() . ' 及以前)');
+        }
+        if (CompanyMonthlyReport::query()->where('month', $month)->exists()) {
+            throw new \RuntimeException($month . ' 的报表已存在,请在列表中编辑重新上传');
+        }
+        $path = $this->store($month, $file);
+        try {
+            $report = CompanyMonthlyReport::query()->create([
+                'month' => $month, 'file_name' => $this->fileName($file), 'file_path' => $path,
+                'file_size' => (int) $file->getSize(), 'created_by' => $adminId, 'updated_by' => $adminId,
+            ]);
+        } catch (\Throwable $e) {
+            Storage::disk(self::DISK)->delete($path);
+            if ($e instanceof \Illuminate\Database\UniqueConstraintViolationException) {
+                throw new \RuntimeException($month . ' 的报表已存在,请在列表中编辑重新上传');
+            }
+            throw $e;
+        }
+        return $this->format($report);
+    }
+
+    /** 编辑:重新上传报表文件,月份不变。 */
+    public function replace(int $id, UploadedFile $file, int $adminId): array
+    {
+        $path = null;
+        try {
+            // 锁住这一行:同时重新上传时依次替换,每次都删掉被替换的那个文件
+            [$report, $old] = DB::transaction(function () use ($id, $file, $adminId, &$path) {
+                $report = CompanyMonthlyReport::query()->lockForUpdate()->findOrFail($id);
+                $old = $report->file_path;
+                $path = $this->store($report->month, $file);
+                $report->fill(['file_name' => $this->fileName($file), 'file_path' => $path, 'file_size' => (int) $file->getSize(), 'updated_by' => $adminId]);
+                $report->save();
+                return [$report, $old];
+            });
+        } catch (\Throwable $e) {
+            if ($path) Storage::disk(self::DISK)->delete($path);
+            throw $e;
+        }
+        if ($old && $old !== $path) Storage::disk(self::DISK)->delete($old);
+        return $this->format($report->fresh());
+    }
+
+    public function download(int $id): BinaryFileResponse
+    {
+        $report = CompanyMonthlyReport::query()->findOrFail($id);
+        $disk = Storage::disk(self::DISK);
+        if (!$report->file_path || !$disk->exists($report->file_path)) {
+            throw new \RuntimeException('报表文件不存在,请联系管理员重新上传');
+        }
+        return response()->download($disk->path($report->file_path), $report->file_name);
+    }
+
+    private function store(string $month, UploadedFile $file): string
+    {
+        $extension = strtolower($file->getClientOriginalExtension());
+        // 按文件头确认确实是 Excel:xlsx 是 zip(PK\x03\x04),xls 和加密的 xlsx 是 OLE 复合文档(D0 CF 11 E0)
+        $head = (string) @file_get_contents($file->getRealPath(), false, null, 0, 4);
+        if (!in_array($extension, self::EXTENSIONS, true) || !in_array($head, ["PK\x03\x04", "\xD0\xCF\x11\xE0"], true)) {
+            throw new \InvalidArgumentException('请上传 Excel 文件(.xlsx 或 .xls)');
+        }
+        $name = $month . '-' . Str::lower(Str::random(16)) . '.' . $extension;
+        $path = $file->storeAs(self::DIR, $name, self::DISK);
+        if (!$path) throw new \RuntimeException('报表保存失败,请重试');
+        return $path;
+    }
+
+    private function fileName(UploadedFile $file): string
+    {
+        $name = trim(str_replace(['/', '\\', "\0"], '', (string) $file->getClientOriginalName()));
+        return mb_substr($name !== '' ? $name : 'report.' . $file->getClientOriginalExtension(), 0, 255);
+    }
+
+    private function format(CompanyMonthlyReport $report): array
+    {
+        return [
+            'id' => (int) $report->id, 'month' => $report->month, 'file_name' => $report->file_name,
+            'file_size' => (int) $report->file_size, 'created_at' => $report->created_at?->format('Y-m-d H:i:s'),
+            'updated_at' => $report->updated_at?->format('Y-m-d H:i:s'),
+        ];
+    }
+}

+ 29 - 0
database/migrations/2026_11_12_120000_create_company_monthly_reports.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/** 公司营业报表:总后台按月手动上传 Excel,入股代理在代理后台查看和下载。 */
+return new class extends Migration
+{
+    public function up(): void
+    {
+        if (Schema::hasTable('company_monthly_reports')) return;
+        Schema::create('company_monthly_reports', function (Blueprint $t) {
+            $t->id();
+            $t->char('month', 7)->unique()->comment('报表月份 YYYY-MM');
+            $t->string('file_name', 255)->comment('上传时的原文件名,下载时使用');
+            $t->string('file_path', 255)->comment('local 磁盘内的路径(不公开访问)');
+            $t->unsignedBigInteger('file_size')->default(0);
+            $t->unsignedBigInteger('created_by')->default(0)->comment('上传的管理员');
+            $t->unsignedBigInteger('updated_by')->default(0)->comment('最后重新上传的管理员');
+            $t->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('company_monthly_reports');
+    }
+};

+ 105 - 0
docs/代理/公司营业报表-前端接入.md

@@ -0,0 +1,105 @@
+# 代理后台-公司营业报表(前端接入,2026-11-12)
+
+总后台每月上传一份公司营业报表(Excel),**入股代理**可以在代理后台查看列表并下载。合营代理不能看。
+
+请求头带 `Authorization: Bearer <agent_token>`。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。
+
+## 菜单
+
+按 `GET /agent/profile` 返回的 `type` 显示菜单:
+
+| 菜单 | 合营 `type=1` | 入股 `type=2` |
+|---|---|---|
+| 公司营业报表 | 隐藏 | 显示 |
+
+合营代理调用下面的接口会返回错误 `仅入股代理可查看公司营业报表`。
+
+## 1. 列表
+
+`GET /agent/company-reports`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `page` | 否 | 页码,默认 1 |
+| `limit` | 否 | 每页条数,默认 20,最大 100 |
+| `start_month` | 否 | 开始月份 `YYYY-MM` |
+| `end_month` | 否 | 结束月份 `YYYY-MM` |
+
+按月份倒序(最新的在前)。
+
+```json
+{
+  "total": 2,
+  "page": 1,
+  "limit": 20,
+  "list": [
+    {
+      "id": 2,
+      "month": "2026-10",
+      "file_name": "10月营业报表.xlsx",
+      "file_size": 52341,
+      "created_at": "2026-11-01 10:20:00",
+      "updated_at": "2026-11-03 15:02:11"
+    },
+    {
+      "id": 1,
+      "month": "2026-09",
+      "file_name": "9月营业报表.xlsx",
+      "file_size": 48810,
+      "created_at": "2026-10-02 09:00:00",
+      "updated_at": "2026-10-02 09:00:00"
+    }
+  ]
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `month` | 报表月份 |
+| `file_name` | 文件名,下载时用这个名字保存 |
+| `file_size` | 文件大小(字节),可以显示成 KB / MB |
+| `created_at` | 首次上传时间 |
+| `updated_at` | 最后更新时间。总后台重新上传后会变,可以显示成「更新于 …」 |
+
+建议列:月份、文件名、大小、更新时间、操作(下载)。
+
+## 2. 下载
+
+`GET /agent/company-reports/download?id=2`
+
+- 成功时直接返回 Excel 文件流(`Content-Disposition: attachment`,文件名是 `file_name`),**不是 JSON**。
+- 失败时返回 JSON,例如 `{"code":-1,"msg":"数据不存在"}`、`{"code":-1,"msg":"报表文件不存在,请联系管理员重新上传"}`、`{"code":-1,"msg":"仅入股代理可查看公司营业报表"}`。
+- 每个代理每分钟最多下载 30 次,超过返回 HTTP 429。
+
+文件不能通过公开链接访问,下载必须带登录头。所以不要用 `<a href>` 或 `window.open`,要用 axios 请求,`responseType: 'blob'`:
+
+```js
+export function downloadCompanyReport(id) {
+  return request({ url: '/agent/company-reports/download', method: 'get', params: { id }, responseType: 'blob' })
+}
+
+// 页面里
+async download(row) {
+  const blob = await downloadCompanyReport(row.id)
+  // 失败时后端返回 JSON,blob.type 是 application/json,需要读出来提示
+  if (blob.type && blob.type.includes('application/json')) {
+    const res = JSON.parse(await blob.text())
+    return this.$message.error(res.msg || '下载失败')
+  }
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = row.file_name
+  a.click()
+  URL.revokeObjectURL(url)
+}
+```
+
+如果 `request` 的响应拦截器会检查 `response.data.code`,blob 响应没有 `code`,会被当成失败。下载接口需要在拦截器最前面放过,比如 `if (response.config.responseType === 'blob') return response.data`。
+
+## 验收
+
+1. 入股代理登录后能看到「公司营业报表」菜单,列表按月份倒序显示。
+2. 点击下载,保存的文件名和列表里的 `file_name` 一致,能用 Excel 打开。
+3. 总后台重新上传某个月的报表后,代理后台这一行的更新时间变了,下载到的是新文件。
+4. 合营代理看不到这个菜单。

+ 6 - 1
docs/前端交付/README.md

@@ -2,6 +2,11 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 公司营业报表(2026-11-12)
+
+- [公司营业报表-总后台前端接入](../总后台/公司营业报表-前端接入.md):新增菜单「公司营业报表」,按月上传 Excel。列表 `GET /admin/companyReport`、新增 `POST /admin/companyReport/create`(月份唯一,可选上个月及以前未上传的月份)、编辑重新上传 `POST /admin/companyReport/update`、下载 `GET /admin/companyReport/download`(文件流,用 blob 下载)。需要角色勾选 `admin/companyReport` 按钮。
+- [公司营业报表-代理后台前端接入](../代理/公司营业报表-前端接入.md):入股代理新增菜单「公司营业报表」,列表 `GET /agent/company-reports`,下载 `GET /agent/company-reports/download`;合营代理隐藏。
+
 ## 首页第三方游戏商户余额(2026-11-10)
 
 - [后台首页第三方游戏商户余额-前端接入](后台首页第三方游戏商户余额-前端接入.md)
@@ -23,7 +28,7 @@
 
 ## 会员列表「上级」显示代理(2026-11-10)
 
-- [会员列表上级显示代理-前端修改单](会员列表上级显示代理-前端修改单.md):总后台会员列表新增 `superior`(上级)和 `system_agent`(所属代理)。「上级邀请人」列改为显示 `superior.label`,例如「合营:agent06」「入股:agent07」「邀请人:inviter01」。
+- [会员列表上级显示代理-前端修改单](会员列表上级显示代理-前端修改单.md):总后台会员列表新增 `superior`(上级)和 `system_agent`(所属代理)。「上级邀请人」列改为显示 `superior.label`,例如「合营:agent06」「邀请人:inviter01(邀请码:INV1001)」;入股代理不显示。
 
 ## 测试代理(2026-11-10)
 

+ 154 - 0
docs/总后台/公司营业报表-前端接入.md

@@ -0,0 +1,154 @@
+# 公司营业报表-总后台前端接入(2026-11-12)
+
+## 功能
+
+总后台新增菜单「公司营业报表」:每月手动上传一份公司营业报表(Excel)。页面有一个列表,支持新增、编辑(重新上传)和下载。上传后,**入股代理**能在代理后台看到并下载(见[代理后台文档](../代理/公司营业报表-前端接入.md)),合营代理看不到。
+
+- 一个月份只能有一份报表(月份唯一)。
+- 新增时只能选**已结束的月份**:最近是上个月(比如今天是 2026-11-12,最近能选 `2026-10`)。更早的月份如果漏传了,也可以补传;当月和以后的月份不能选。
+- 编辑**只能重新上传文件**,月份不能改。任何月份的报表都可以重新上传,上传后旧文件被替换。
+- 文件只支持 Excel:`.xlsx`、`.xls`(包括设了打开密码的),最大 20MB。后端会检查文件内容,改扩展名的文件会被拒绝。
+
+## 菜单和按钮权限
+
+前端新增菜单「公司营业报表」。在菜单管理里给这个页面建下面这些按钮,并在角色里勾选:
+
+| 按钮 | URI |
+|---|---|
+| 列表 / 下载 | `admin/companyReport` |
+| 新增 | `admin/companyReport/create` |
+| 编辑(重新上传) | `admin/companyReport/update` |
+
+权限规则:超级管理员(ID 1)直接放行;其他管理员的角色必须勾选对应按钮。某个按钮还没建时,勾选了本模块任意按钮(`admin/companyReport` 开头)即可。**一个按钮都没建时,只有超级管理员能用**。没有权限时返回 `code=-1`、`msg=无运营配置管理权限`。
+
+接口沿用总后台登录凭证 `Authorization: Bearer <admin_token>`。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,`code` 不为 0 时直接提示 `msg`。
+
+## 1. 列表
+
+`GET /admin/companyReport`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `page` | 否 | 页码,默认 1 |
+| `limit` | 否 | 每页条数,默认 20,最大 100 |
+| `start_month` | 否 | 开始月份 `YYYY-MM` |
+| `end_month` | 否 | 结束月份 `YYYY-MM` |
+
+按月份倒序(最新的在前)。
+
+```json
+{
+  "total": 2,
+  "page": 1,
+  "limit": 20,
+  "allowed_month": "2026-10",
+  "list": [
+    {
+      "id": 2,
+      "month": "2026-10",
+      "file_name": "10月营业报表.xlsx",
+      "file_size": 52341,
+      "created_at": "2026-11-01 10:20:00",
+      "updated_at": "2026-11-03 15:02:11",
+      "created_by": 1,
+      "created_by_name": "root",
+      "updated_by": 2,
+      "updated_by_name": "finance"
+    }
+  ]
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `allowed_month` | 最近可以新增的月份(上个月)。新增弹窗的月份默认选它,比它晚的月份置灰 |
+| `month` | 报表月份 |
+| `file_name` | 上传时的文件名,下载时也用这个名字 |
+| `file_size` | 文件大小(字节),可以显示成 KB / MB |
+| `created_at` / `created_by_name` | 首次上传时间 / 上传人 |
+| `updated_at` / `updated_by_name` | 最后上传时间 / 上传人(重新上传后会变) |
+
+建议列:月份、文件名、大小、上传人、上传时间、最后更新人、最后更新时间、操作(下载、编辑)。
+
+## 2. 新增
+
+`POST /admin/companyReport/create`,`multipart/form-data`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `month` | 是 | 月份 `YYYY-MM`,不能晚于列表返回的 `allowed_month`(上个月),而且这个月还没有报表 |
+| `file` | 是 | Excel 文件,`.xlsx` 或 `.xls`,不超过 20MB |
+
+表单:
+- 月份用月份选择器(`el-date-picker type="month"`,`value-format="yyyy-MM"`),晚于 `allowed_month` 的月份置灰;默认值用 `allowed_month`。
+- 已经有报表的月份会被后端拒绝,提示用编辑重新上传。
+- 上传组件 `accept=".xlsx,.xls"`,提交前在前端先检查大小不超过 20MB。
+
+成功返回新记录(字段同列表,没有 `created_by_name` 等上传人字段),然后刷新列表。
+
+可能的错误 `msg`:
+
+| msg | 说明 |
+|---|---|
+| `只能上传已结束月份的报表(2026-10 及以前)` | 选了当月或以后的月份 |
+| `2026-10 的报表已存在,请在列表中编辑重新上传` | 这个月已经有报表 |
+| `请上传 Excel 文件(.xlsx 或 .xls)` | 文件不是 Excel |
+| `报表文件不能超过 20MB` | 文件过大 |
+| `请选择月份` / `请上传报表文件` | 缺少参数 |
+
+## 3. 编辑(重新上传)
+
+`POST /admin/companyReport/update`,`multipart/form-data`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `id` | 是 | 列表里的 `id` |
+| `file` | 是 | 新的 Excel 文件,规则同新增 |
+
+编辑弹窗里月份**只显示、不能改**,只能选新文件。成功后返回更新后的记录,旧文件会被删除,入股代理下载到的是新文件。
+
+## 4. 下载
+
+`GET /admin/companyReport/download?id=2`
+
+- 成功时直接返回文件流(`Content-Disposition: attachment`,文件名是 `file_name`),**不是 JSON**。
+- 失败时返回 JSON,例如 `{"code":-1,"msg":"数据不存在"}` 或 `{"code":-1,"msg":"报表文件不存在,请联系管理员重新上传"}`。
+
+文件不能通过公开链接访问,下载必须带登录头。所以不要用 `<a href>` 或 `window.open`,要用 axios 请求,`responseType: 'blob'`:
+
+```js
+export function downloadCompanyReport(id) {
+  return request({ url: '/companyReport/download', method: 'get', params: { id }, responseType: 'blob' })
+}
+
+// 页面里
+async download(row) {
+  const blob = await downloadCompanyReport(row.id)
+  // 失败时后端返回 JSON,blob.type 是 application/json,需要读出来提示
+  if (blob.type && blob.type.includes('application/json')) {
+    const res = JSON.parse(await blob.text())
+    return this.$message.error(res.msg || '下载失败')
+  }
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = row.file_name
+  a.click()
+  URL.revokeObjectURL(url)
+}
+```
+
+注意:现在的 `request` 响应拦截器会读 `response.data.code`,blob 响应没有 `code`,会被当成失败。下载接口需要在拦截器里放过,比如 `if (response.config.responseType === 'blob') return response.data` 写在最前面。
+
+## 上线
+
+后端执行迁移 `2026_11_12_120000_create_company_monthly_reports`。文件存在服务器 `storage/app/company-reports/`,不对外公开。如果部署了多台服务器,这个目录需要共享。
+
+## 验收
+
+1. 新增时当月和以后的月份不能选;强行提交提示「只能上传已结束月份的报表…」。漏传的更早月份可以补传。
+2. 上传 `.xlsx` 成功,列表出现这一行;同一月份再新增,提示已存在。
+3. 上传改了扩展名的非 Excel 文件,提示「请上传 Excel 文件(.xlsx 或 .xls)」;设了打开密码的 xlsx 可以正常上传。
+6. 角色没有勾选本模块按钮的管理员,打开页面提示「无运营配置管理权限」。
+4. 编辑重新上传后,文件名、最后更新人和最后更新时间都变了;下载得到的是新文件。
+5. 入股代理登录代理后台,能看到并下载这份报表;合营代理看不到。

+ 9 - 0
routes/admin.php

@@ -122,6 +122,15 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/data-quality', [Reporting::class, 'quality'])->middleware('operations.admin');
         });
 
+        // 公司营业报表:按月上传 Excel(入股代理在代理后台下载)。
+        // operations.admin:超级管理员,或角色勾选了 admin/companyReport 模块的按钮;没建按钮时不对其他管理员开放。
+        Route::prefix('/companyReport')->middleware('operations.admin')->group(function () {
+            Route::get('/', [\App\Http\Controllers\admin\CompanyReport::class, 'index']);
+            Route::post('/create', [\App\Http\Controllers\admin\CompanyReport::class, 'create']);
+            Route::post('/update', [\App\Http\Controllers\admin\CompanyReport::class, 'update']);
+            Route::get('/download', [\App\Http\Controllers\admin\CompanyReport::class, 'download'])->defaults('permission_uri', 'admin/companyReport');
+        });
+
         Route::prefix('/maintenance')->middleware('operations.admin')->group(function () {
             Route::get('/', [Maintenance::class, 'index']);
             Route::get('/options', [Maintenance::class, 'options'])->defaults('permission_uri', 'admin/maintenance');

+ 2 - 0
routes/agent.php

@@ -63,5 +63,7 @@ Route::middleware('agent.auth')->group(function () {
     Route::post('/equity/orders/cancel', [EquityController::class, 'cancel']);
     Route::get('/reports', [ReportController::class, 'index']);
     Route::get('/reports/period', [ReportController::class, 'period']);
+    Route::get('/company-reports', [\App\Http\Controllers\agent\CompanyReportController::class, 'index']);
+    Route::get('/company-reports/download', [\App\Http\Controllers\agent\CompanyReportController::class, 'download'])->middleware('throttle:30,1');
     Route::post('/upload/image', [\App\Http\Controllers\agent\UploadController::class, 'image'])->middleware('throttle:30,1');
 });

+ 126 - 0
tests/Integration/CompanyMonthlyReportTest.php

@@ -0,0 +1,126 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\CompanyReport;
+use App\Http\Controllers\agent\CompanyReportController;
+use App\Models\Agent\Agent;
+use App\Services\CompanyMonthlyReportService;
+use Carbon\Carbon;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Filesystem\FilesystemManager;
+use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Tests\Support\AgentTestEnvironment;
+
+/** 公司营业报表:总后台按月上传 / 重新上传,入股代理查看和下载。 */
+class CompanyMonthlyReportTest extends TestCase
+{
+    use AgentTestEnvironment { setUp as environmentSetUp; tearDown as environmentTearDown; }
+
+    private string $root;
+    private array $temp = [];
+
+    protected function setUp(): void
+    {
+        $this->environmentSetUp();
+        Carbon::setTestNow('2026-11-12 10:00:00');
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_120000_create_company_monthly_reports.php')->up();
+        Schema::create('admin', function (Blueprint $t) { $t->id(); $t->string('username'); });
+        DB::table('admin')->insert([['id' => 1, 'username' => 'root'], ['id' => 2, 'username' => 'finance']]);
+        $this->root = sys_get_temp_dir() . '/company-report-' . bin2hex(random_bytes(4));
+        config(['filesystems.disks.local' => ['driver' => 'local', 'root' => $this->root]]);
+        $this->app->instance('filesystem', new FilesystemManager($this->app));
+    }
+
+    protected function tearDown(): void
+    {
+        Carbon::setTestNow();
+        foreach ($this->temp as $file) @unlink($file);
+        if (is_dir($this->root)) { array_map('unlink', glob($this->root . '/company-reports/*') ?: []); @rmdir($this->root . '/company-reports'); @rmdir($this->root); }
+        $this->environmentTearDown();
+    }
+
+    private function excel(string $name, string $head = "PK\x03\x04"): UploadedFile
+    {
+        $path = tempnam(sys_get_temp_dir(), 'xls');
+        file_put_contents($path, $head . random_bytes(32));
+        $this->temp[] = $path;
+        return new UploadedFile($path, $name, null, null, true);
+    }
+
+    private function admin(string $action, array $params = [], array $files = [], int $adminId = 1)
+    {
+        $request = Request::create('/admin/companyReport/' . $action, $files ? 'POST' : 'GET', $params, [], $files);
+        $request->user = (object) ['id' => $adminId];
+        $this->app->instance('request', $request);
+        $response = (new CompanyReport())->{$action === '' ? 'index' : $action}($request, new CompanyMonthlyReportService());
+        return $response instanceof BinaryFileResponse ? $response : $response->getData(true);
+    }
+
+    private function agentCall(Agent $agent, string $action, array $params = [])
+    {
+        $request = Request::create('/agent/company-reports', 'GET', $params);
+        $request->attributes->set('agent', $agent);
+        $this->app->instance('request', $request);
+        $response = (new CompanyReportController())->{$action}($request, new CompanyMonthlyReportService());
+        return $response instanceof BinaryFileResponse ? $response : $response->getData(true);
+    }
+
+    public function test_admin_uploads_last_month_once_and_reuploads_through_edit(): void
+    {
+        $this->assertSame('2026-10', CompanyMonthlyReportService::allowedMonth());
+        $wrong = $this->admin('create', ['month' => '2026-11'], ['file' => $this->excel('11月.xlsx')]);
+        $this->assertSame('只能上传已结束月份的报表(2026-10 及以前)', $wrong['msg']);
+        $this->assertSame('请上传 Excel 文件(.xlsx 或 .xls)', $this->admin('create', ['month' => '2026-10'], ['file' => $this->excel('a.pdf')])['msg']);
+        $this->assertSame('请上传 Excel 文件(.xlsx 或 .xls)', $this->admin('create', ['month' => '2026-10'], ['file' => $this->excel('fake.xlsx', 'abcd')])['msg'], 'content must be excel');
+
+        $encrypted = $this->admin('create', ['month' => '2026-08'], ['file' => $this->excel('8月-加密.xlsx', "\xD0\xCF\x11\xE0")]);
+        $this->assertSame([0, '2026-08'], [$encrypted['code'], $encrypted['data']['month'] ?? null], 'a missed earlier month can be added; password-protected xlsx is OLE');
+        $created = $this->admin('create', ['month' => '2026-10'], ['file' => $this->excel('10月营业报表.xlsx')]);
+        $this->assertSame(0, $created['code'], $created['msg']);
+        $this->assertSame(['2026-10', '10月营业报表.xlsx'], [$created['data']['month'], $created['data']['file_name']]);
+        $this->assertArrayNotHasKey('file_path', $created['data']);
+        $this->assertSame('2026-10 的报表已存在,请在列表中编辑重新上传', $this->admin('create', ['month' => '2026-10'], ['file' => $this->excel('again.xlsx')])['msg']);
+        $oldPath = DB::table('company_monthly_reports')->where('month', '2026-10')->value('file_path');
+        $this->assertFileExists($this->root . '/' . $oldPath);
+
+        $updated = $this->admin('update', ['id' => $created['data']['id']], ['file' => $this->excel('10月营业报表-修正.xls', "\xD0\xCF\x11\xE0")], 2);
+        $this->assertSame(0, $updated['code'], $updated['msg']);
+        $this->assertSame(['2026-10', '10月营业报表-修正.xls'], [$updated['data']['month'], $updated['data']['file_name']]);
+        $this->assertFileDoesNotExist($this->root . '/' . $oldPath, 'old file is removed after re-upload');
+
+        $list = $this->admin('', [])['data'];
+        $this->assertSame([2, '2026-10'], [$list['total'], $list['allowed_month']]);
+        $this->assertSame(['2026-10', '2026-08'], array_column($list['list'], 'month'));
+        $this->assertSame(['root', 'finance'], [$list['list'][0]['created_by_name'], $list['list'][0]['updated_by_name']]);
+        $file = $this->admin('download', ['id' => $created['data']['id']]);
+        $this->assertInstanceOf(BinaryFileResponse::class, $file);
+        $this->assertStringContainsString('attachment', (string) $file->headers->get('content-disposition'));
+        $this->assertSame('数据不存在', $this->admin('download', ['id' => 999])['msg']);
+    }
+
+    public function test_only_equity_agents_can_list_and_download(): void
+    {
+        Carbon::setTestNow('2026-10-05 10:00:00');
+        $this->admin('create', ['month' => '2026-09'], ['file' => $this->excel('9月.xlsx')]);
+        Carbon::setTestNow('2026-11-12 10:00:00');
+        $id = $this->admin('create', ['month' => '2026-10'], ['file' => $this->excel('10月.xlsx')])['data']['id'];
+
+        $equity = $this->agent(Agent::TYPE_EQUITY);
+        $list = $this->agentCall($equity, 'index');
+        $this->assertSame(0, $list['code'], $list['msg']);
+        $this->assertSame(['2026-10', '2026-09'], array_column($list['data']['list'], 'month'), 'newest month first');
+        $this->assertArrayNotHasKey('created_by', $list['data']['list'][0], 'admin info stays in the admin');
+        $this->assertSame(['2026-09'], array_column($this->agentCall($equity, 'index', ['end_month' => '2026-09'])['data']['list'], 'month'));
+        $this->assertInstanceOf(BinaryFileResponse::class, $this->agentCall($equity, 'download', ['id' => $id]));
+
+        $joint = $this->agent();
+        $this->assertSame('仅入股代理可查看公司营业报表', $this->agentCall($joint, 'index')['msg']);
+        $this->assertSame('仅入股代理可查看公司营业报表', $this->agentCall($joint, 'download', ['id' => $id])['msg']);
+    }
+}

+ 2 - 2
tests/Support/AgentTestEnvironment.php

@@ -53,8 +53,8 @@ trait AgentTestEnvironment
         $validator->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
         $this->app->instance('validator', $validator);
         $this->app->instance('request', Request::create('/'));
-        Request::macro('validate', function (array $rules) {
-            return app('validator')->make($this->all(), $rules)->validate();
+        Request::macro('validate', function (array $rules, array $messages = []) {
+            return app('validator')->make($this->all(), $rules, $messages)->validate();
         });
         $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new JsonResponseFactory());
         Schema::create('users', function (Blueprint $t) {