Bläddra i källkod

独立活动:净亏邀请记录展示中文字段与名单

活动记录列表补充用户与邀请人账号;净亏邀请活动的账户和奖励记录展示
发放方式、邀请人与被邀请人名单及实际发放的优惠券数量,并更新前端交付文档。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doge 1 dag sedan
förälder
incheckning
a172d83955

+ 84 - 1
app/Services/PromotionAdminService.php

@@ -162,6 +162,11 @@ class PromotionAdminService
             if (isset($input['status'])) $q->where('status', $input['status']);
             $rows = (clone $q)->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()
                 ->map(function ($row) { $row->categories = json_decode($row->categories, true); return $row; });
+            $accounts = $this->recordAccounts($rows->flatMap(fn ($row) => [$row->user_id, $row->inviter_user_id])->all());
+            foreach ($rows as $row) {
+                $row->account = $accounts[(int)$row->user_id] ?? null;
+                $row->inviter_account = $accounts[(int)$row->inviter_user_id] ?? null;
+            }
             return ['total' => $q->count(), 'data' => $rows];
         }
         $q = DB::table('promotion_accounts as a')->where('a.activity_id', $input['activity_id']);
@@ -171,6 +176,84 @@ class PromotionAdminService
             $table = match ($input['kind']) { 'rounds' => 'promotion_rounds', 'tasks' => 'promotion_task_grants', 'spins' => 'promotion_spins', 'rewards' => 'promotion_rewards' };
             $q->join($table . ' as r', 'r.account_id', '=', 'a.id')->select(['r.*', 'a.user_id', 'a.activity_id'])->orderByDesc('r.id');
         }
-        return ['total' => $q->count(), 'data' => $q->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()];
+        $total = (clone $q)->count();
+        $rows = $q->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get();
+        if (in_array($input['kind'], ['accounts', 'rewards'], true) && $rows->isNotEmpty()
+            && (int)DB::table('promotion_activities')->where('id', $input['activity_id'])->value('type') === PromotionRules::LOSS) {
+            $this->lossRecordPeople($rows, $input['kind']);
+        }
+        return ['total' => $total, 'data' => $rows];
+    }
+
+    /** Loss-invite record display only: historical recipients and actual issued coupon quantities. */
+    private function lossRecordPeople($rows, string $kind): void
+    {
+        $rewards = $kind === 'rewards' ? $rows : DB::table('promotion_rewards')->whereIn('account_id', $rows->pluck('id'))
+            ->where('round_no', 1)->get();
+        $byAccount = $rewards->keyBy('account_id');
+        $couponRewards = $rewards->filter(fn ($reward) => ($this->recordSnapshot($reward->rule_snapshot)['reward_mode'] ?? 'cash') === 'coupon');
+        $groups = $couponRewards->isEmpty() ? collect() : DB::table('promotion_coupons')->whereIn('reward_id', $couponRewards->pluck('id'))
+            ->select(['reward_id', 'user_id', 'role', 'min_amount', 'discount'])->selectRaw('COUNT(*) AS quantity')
+            ->groupBy('reward_id', 'user_id', 'role', 'min_amount', 'discount')->orderBy('min_amount')->orderBy('discount')->get();
+        $coupons = $issuedInvitees = [];
+        $people = $rows->pluck('user_id')->all();
+        foreach ($groups as $group) {
+            $coupons[(int)$group->reward_id][(int)$group->user_id][$group->role][] = [
+                'min_amount' => bcadd((string)$group->min_amount, '0', 2), 'discount' => bcadd((string)$group->discount, '0', 2), 'quantity' => (int)$group->quantity,
+            ];
+            if ($group->role === 'invitee') $issuedInvitees[(int)$group->reward_id][] = (int)$group->user_id;
+            $people[] = (int)$group->user_id;
+        }
+        $contexts = [];
+        foreach ($rows as $row) {
+            $reward = $kind === 'rewards' ? $row : $byAccount->get($row->id);
+            $application = $this->recordSnapshot($row->application_snapshot ?? null);
+            $rules = $reward ? $this->recordSnapshot($reward->rule_snapshot) : ($application['rules'] ?? []);
+            if (!is_array($rules)) $rules = [];
+            $mode = ($reward || $rules) ? ($rules['reward_mode'] ?? 'cash') : null;
+            $ids = $mode === 'coupon' && isset($rules['coupon_invitees']) ? $rules['coupon_invitees'] : ($rules['qualified_invitees'] ?? []);
+            $ids = $this->recordIds(array_merge(is_array($ids) ? $ids : [], $issuedInvitees[(int)($reward->id ?? 0)] ?? []));
+            $contexts[] = [$row, $reward, in_array($mode, ['cash', 'coupon'], true) ? $mode : null, $ids];
+            $people = array_merge($people, $ids);
+        }
+        $accounts = $this->recordAccounts($people);
+        foreach ($contexts as [$row, $reward, $mode, $ids]) {
+            $rewardId = (int)($reward->id ?? 0); $owner = (int)$row->user_id;
+            $row->reward_mode = $mode;
+            if ($kind === 'accounts') {
+                $row->reward_id = $reward ? (int)$reward->id : null;
+                $row->reward_status = $reward ? $reward->status : null;
+            }
+            $row->inviter = ['id' => $owner, 'account' => $accounts[$owner] ?? null, 'coupons' => $coupons[$rewardId][$owner]['inviter'] ?? []];
+            $row->invitees = array_map(fn ($id) => ['id' => $id, 'account' => $accounts[$id] ?? null,
+                'coupons' => $coupons[$rewardId][$id]['invitee'] ?? []], $ids);
+        }
+    }
+
+    private function recordSnapshot($value): array
+    {
+        $value = is_string($value) ? json_decode($value, true) : $value;
+        return is_array($value) ? $value : [];
+    }
+
+    private function recordIds(array $ids): array
+    {
+        $valid = [];
+        foreach ($ids as $id) {
+            $id = is_int($id) || is_string($id) ? filter_var($id, FILTER_VALIDATE_INT) : false;
+            if ($id !== false && $id > 0) $valid[$id] = $id;
+        }
+        return array_values($valid);
+    }
+
+    private function recordAccounts(array $ids): array
+    {
+        $accounts = [];
+        foreach (array_chunk($this->recordIds($ids), 1000) as $chunk) {
+            foreach (DB::table('users')->whereIn('id', $chunk)->get(['id', 'account']) as $user) {
+                $accounts[(int)$user->id] = $user->account === null || $user->account === '' ? null : (string)$user->account;
+            }
+        }
+        return $accounts;
     }
 }

+ 5 - 0
docs/前端交付/README.md

@@ -2,6 +2,11 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 记录列表展示增补(2026-11-02)
+
+- [净亏邀请记录-中文字段与名单展示](bot-28/后台/独立活动/净亏邀请记录-中文字段与名单展示.md):给管理后台前端,包含新增返回字段、两类记录的中文标签、ID/账号及获券的列表展示方式。
+- 本次新增 1 份独立说明,当前来源文件合计 24 份;下方旧批次范围保留。
+
 ## 2026-10-31 增补
 
 - [后台实时推送-前端接入](bot-28/后台/后台实时推送-前端接入.md):待上线方案,使用 Laravel Echo + Reverb。包含连接参数、频道授权、16 个频道、事件格式、表格更新、断线补发与登录失效处理。

+ 1 - 0
docs/前端交付/bot-28/后台/README.md

@@ -4,6 +4,7 @@
 
 | 功能 | 文档 |
 |---|---|
+| 净亏邀请:记录中文、邀请人与被邀请人账号及获券 | [净亏邀请记录-中文字段与名单展示](独立活动/净亏邀请记录-中文字段与名单展示.md) |
 | 实时推送:Laravel Echo + Reverb、频道授权、表格更新、断线补发(2026-10-31 增补) | [后台实时推送-前端接入](后台实时推送-前端接入.md) |
 | 公告:多语言、展示平台、发布时段、启停和日志 | [公告系统-后台接口](公告系统-后台接口.md) |
 | 信息:发送对象、多语言、定时发送、撤回和日志 | [信息管理-后台接口](信息管理-后台接口.md) |

+ 2 - 0
docs/前端交付/bot-28/后台/独立活动-后台接口.md

@@ -11,3 +11,5 @@
 本页仅保留导航;每个活动的参数、示例、上下架、资格和发奖说明均在各自文档内。
 
 新增按钮与新增类型下拉使用 `GET /admin/promotion/options` 返回的 `create_types`,当前只有首充奖励(`value=8`)。`types` 仍提供三种类型用于筛选。固定活动从列表读取现有 `id` 后编辑保存;净亏邀请改为单页平铺表单,直接用 `GET /admin/promotion/detail?type=7` 读取,不再经过列表。
+
+申请记录、发奖记录的中文标签、被邀请人账号及获券列表接入见[净亏邀请记录-中文字段与名单展示](独立活动/净亏邀请记录-中文字段与名单展示.md)。

+ 193 - 0
docs/前端交付/bot-28/后台/独立活动/净亏邀请记录-中文字段与名单展示.md

@@ -0,0 +1,193 @@
+# 净亏邀请记录:中文字段、被邀请人及获券展示
+
+面向**管理后台前端**,用于改造“申请记录”“发奖记录”和“已发优惠券”列表及详情。本次沿用现有接口,增加名单、账号和获券展示数据;不补发历史奖励。
+
+## 1. 现有截图中的三个人在哪里
+
+截图发奖记录 `id=3`、领奖会员 `user_id=164`、关联申请 `account_id=11865`。其规则中有 `qualified_invitees=[168,169,170]`,这就是发奖时达标的三名会员。
+
+这条历史记录的规则未指定优惠券模式,按兼容规则属于**现金奖励**,并有奖励入账流水 `credited_log_id=20800`。500 是邀请人 164 的现金奖励。三名被邀请人用于满足领取条件,这条记录没有给他们发券。
+
+更新接口后,前端直接使用新增的 `inviter`、`invitees` 显示 ID、账号和各自获券,无需再从规则快照解析名单或逐人查询账号。
+
+## 2. 请求和返回位置
+
+在 bot-28 后台 API 主机请求,沿用管理员请求头 `Authorization: Bearer <token>`。
+
+| 页面 | 请求 |
+| --- | --- |
+| 申请记录 | `GET /admin/promotion/records?activity_id=1&kind=accounts&page=1&limit=20` |
+| 发奖记录 | `GET /admin/promotion/records?activity_id=1&kind=rewards&page=1&limit=20` |
+| 已发优惠券 | `GET /admin/promotion/records?activity_id=1&kind=coupons&page=1&limit=20` |
+
+成功 `code=0`,列表位于 `data.data`,总记录数位于 `data.total`;失败展示 `msg`。可传 `user_id`:申请、发奖列表筛选该邀请人;已发券列表匹配持券人或邀请人。`limit` 最大 100。
+
+只有已发优惠券列表支持 `status=unused/used` 筛选。申请、发奖列表暂不提供奖励状态筛选参数。
+
+下述申请、发奖列表新增字段用于净亏邀请(活动类型 `7`),不改变原分页、筛选和原有字段。其他活动类型不增加这组名单字段。
+
+## 3. 新增字段及列表列名
+
+| 返回字段 | 中文名称 / 用法 |
+| --- | --- |
+| `reward_mode` | 奖励方式:`cash` 现金、`coupon` 优惠券;尚无规则时为 `null`,显示“—” |
+| `inviter.id` | 邀请人会员 ID;申请列表可标“申请会员 ID”,发奖列表可标“领奖会员 ID” |
+| `inviter.account` | 邀请人账号 |
+| `inviter.coupons` | 邀请人本次获得的券,按满减档位合并 |
+| `invitees` | 本次关联的被邀请人列表 |
+| `invitees[].id` | 被邀请人会员 ID |
+| `invitees[].account` | 被邀请人账号 |
+| `invitees[].coupons` | 该被邀请人本次获得的券,按满减档位合并 |
+| `reward_id` | 仅申请记录新增:关联发奖记录 ID;未生成发奖记录时为 `null` |
+| `reward_status` | 仅申请记录新增:关联发奖状态;未生成时为 `null`,其余枚举同发奖记录的 `status` |
+
+建议两张列表都增加以下列,不要把嵌套对象直接渲染成原始 JSON:
+
+| 列名 | 展示示例 |
+| --- | --- |
+| 邀请人(ID / 账号) | `164 / demo164` |
+| 邀请人获得的券 | `满100抵50 × 2张;满200抵100 × 1张` |
+| 被邀请人及获券 | 每人一行,例如 `168 / demo168:满100抵100 × 1张`,其余会员继续列出 |
+| 奖励方式 | 现金 / 优惠券 |
+
+现金记录也返回达标会员的 ID 和账号,但双方 `coupons=[]`,显示“本次未发券”。不要把三名达标会员渲染成三笔现金收款记录。
+
+尚未发奖的申请返回 `reward_id=null`、`reward_status=null`、`invitees=[]`,显示“尚未生成发奖名单”。这不表示没有下级,也不表示当前有效邀请人数为 0;这里展示的是与发奖记录关联的历史名单,不是实时邀请进度。
+
+账号取当前可查询的会员账号,不是发奖时的账号快照。账号未设置或会员记录已不存在时,ID 仍保留,`account=null`,显示“—”。这里的会员 ID 对应原记录的 `user_id`,不要当成会员编号 `member_id`。
+
+## 4. 获券数组格式
+
+`inviter.coupons` 和 `invitees[].coupons` 中的每一项:
+
+| 字段 | 中文名称 | 类型 |
+| --- | --- | --- |
+| `min_amount` | 使用门槛 | 金额字符串 |
+| `discount` | 抵扣金额 | 金额字符串 |
+| `quantity` | 本次发放张数 | 整数 |
+
+前端显示“满 `min_amount` 抵 `discount` × `quantity` 张”。金额沿用活动接口的展示格式:整数不带 `.00`,有小数时保留两位。账号按文本展示,保留可能存在的前导零。
+
+数量来自实际发出的券,已使用、已过期的券仍计入**获得张数**。不要把它当成当前可用张数,也不要根据活动当前配置自行生成券档位。没有给被邀请人配置券或该人未获券时,其 `coupons` 为空。
+
+现金记录展示发奖时的达标名单;优惠券记录展示本次计入发券的名单,并包含实际获券人。历史快照中其他达标会员不自动变成本次收券人。
+
+以下为优惠券模式的响应片段,仅演示结构;账号为示例,**不代表截图中的历史现金记录被改成了券奖励**:
+
+```json
+{
+  "code": 0,
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "id": 3003,
+        "activity_id": 1,
+        "user_id": 164,
+        "account_id": 20001,
+        "amount": "500",
+        "status": "paid",
+        "reward_mode": "coupon",
+        "inviter": {
+          "id": 164,
+          "account": "demo164",
+          "coupons": [
+            {"min_amount": "100", "discount": "50", "quantity": 2},
+            {"min_amount": "200", "discount": "100", "quantity": 1}
+          ]
+        },
+        "invitees": [
+          {"id": 168, "account": "demo168", "coupons": [{"min_amount": "100", "discount": "100", "quantity": 1}]},
+          {"id": 169, "account": "demo169", "coupons": [{"min_amount": "100", "discount": "100", "quantity": 1}]},
+          {"id": 170, "account": "demo170", "coupons": [{"min_amount": "100", "discount": "100", "quantity": 1}]}
+        ]
+      }
+    ]
+  }
+}
+```
+
+列表总数仍是申请或发奖记录数,不是被邀请人人数或券张数。每条记录只展示属于这笔发奖的券,不会混入该会员其他发奖记录的券。
+
+## 5. 申请记录:原有英文字段中文名
+
+| 字段 | 中文名称 | 净亏邀请页面建议 |
+| --- | --- | --- |
+| `id` | 申请记录 ID | 显示 |
+| `activity_id` | 活动 ID | 显示或隐藏在活动页上下文中 |
+| `user_id` | 申请会员 ID | 账号使用 `inviter.account` |
+| `round_no` | 参与轮次 | 净亏邀请固定为 1,隐藏 |
+| `spins` | 剩余抽奖次数 | 仅转盘使用,净亏邀请隐藏 |
+| `applied_at` | 申请时间 | 显示;为空时显示“未申请” |
+| `application_snapshot` | 申请时规则 | 不直接显示原始 JSON,按下表展示摘要 |
+| `created_at` | 创建时间 | 显示 |
+| `updated_at` | 更新时间 | 显示 |
+
+`application_snapshot` 当前是 JSON 字符串,展示摘要时先解析;兼容已经解析成对象的数据:
+
+| 解析后的字段 | 中文名称 |
+| --- | --- |
+| `loss` | 申请时净亏 |
+| `from` | 统计开始时间 |
+| `rules.loss_amount` | 申请所需净亏 |
+| `rules.invite_count` | 需邀请有效会员数 |
+| `rules.reward_amount` | 申请时现金奖励金额 / 邀请人券总额上限,按奖励方式显示 |
+| `rules.turnover_ratio` | 解冻投注倍数,仅现金模式需要时展示 |
+
+截图可整理为“申请时净亏 550、申请门槛 500、需邀请 3 人、现金奖励 500”,不要把整段申请规则挤在一行。`application_snapshot.policy`、`application_snapshot.invite_user_cursor` 属于内部核对信息,不作为业务详情展示。
+
+## 6. 发奖记录:原有英文字段中文名
+
+| 字段 | 中文名称 | 展示建议 |
+| --- | --- | --- |
+| `id` | 发奖记录 ID | 显示 |
+| `activity_id` | 活动 ID | 依页面上下文决定是否重复显示 |
+| `user_id` | 领奖会员 ID(邀请人) | 账号使用 `inviter.account` |
+| `account_id` | 关联申请记录 ID | 对应申请记录的 `id` |
+| `round_no` | 发奖轮次 | 净亏邀请固定为 1,隐藏 |
+| `amount` | 现金奖励金额 / 优惠券抵扣总额 | 按本行 `reward_mode` 显示对应列名 |
+| `required_flow` | 解冻所需有效投注额 | 现金模式为 0 时可显示“无需解冻”;券模式隐藏 |
+| `status` | 发奖状态 | 按下表翻译 |
+| `credited_log_id` | 奖励入账流水 ID | 券模式隐藏;无值显示“—” |
+| `unlocked_log_id` | 解冻流水 ID | 无值显示“—” |
+| `rule_snapshot` | 发奖时规则 | 不直接显示原始 JSON;名单和获券使用新增字段 |
+| `flow_snapshot` | 流水统计快照 | 内部字段,隐藏,不展开其中内容 |
+| `unlocked_at` | 解冻时间 | 无值显示“—” |
+| `created_at` | 创建时间 | 不改称“实际发奖时间” |
+| `updated_at` | 更新时间 | 显示 |
+
+| `status` / `reward_status` | 中文 |
+| --- | --- |
+| `reserved` | 待发放 |
+| `locked` | 待解冻(已入冻结余额) |
+| `paid` | 已发放 |
+| `null` | 暂无发奖记录,仅申请记录适用 |
+
+金额为 0 或无需解冻不代表已经发放,仍按状态字段展示。历史记录缺少奖励模式时,接口按现金模式兼容;不要使用活动现在的配置去覆盖旧记录的奖励方式和券档位。
+
+## 7. 已发优惠券列表的账号补充
+
+`kind=coupons` 每条券记录新增 `account`(持券会员账号)、`inviter_account`(邀请人账号),原有字段保留:
+
+| 字段 | 中文名称 |
+| --- | --- |
+| `id` | 券 ID |
+| `reward_id` | 来源发奖记录 ID |
+| `user_id` / `account` | 持券会员 ID / 账号 |
+| `inviter_user_id` / `inviter_account` | 邀请人 ID / 账号 |
+| `role=inviter` | 邀请人奖励券 |
+| `role=invitee` | 被邀请人奖励券 |
+| `min_amount` / `discount` | 使用门槛 / 抵扣金额 |
+| `status=unused` | 未使用;不表示一定在可用时间内 |
+| `status=used` | 已使用 |
+
+发奖列表中的获券摘要使用本行 `inviter.coupons`、`invitees[].coupons` 即可。不要用某一页“已发优惠券”的数据自行拼出获券总数,分页可能只包含部分券。
+
+## 8. 前端验收
+
+- 所有列名使用中文;净亏邀请不展示 `spins`、固定轮次或内部统计快照。
+- 截图历史现金记录显示邀请人 164、三名达标会员及各自账号;双方获券显示“本次未发券”。
+- 优惠券模式同一行显示邀请人及每名被邀请人的 ID、账号、满减档位和获得张数。
+- 未发奖申请显示“尚未生成发奖名单”;账号缺失显示“—”,保留会员 ID。
+- 券使用或过期后,记录页的获得张数保持不变;分页总数仍是主记录数。
+- 更新接口后再启用这些列;历史奖励方式和金额保持原样。

+ 19 - 2
docs/前端交付/交付清单.json

@@ -26,8 +26,8 @@
       "role": "后台",
       "kind": "目录",
       "git_status": "M",
-      "source_sha256": "e372b92a6d19ea20578c17b099a1b7c3595bf21f625f8054e83fefbb6770a52d",
-      "delivery_sha256": "9f178fc88b8441bc2686b9a80aaa2dd9b3fdf29dcbc0c3351c7f6e8927308bd0",
+      "source_sha256": "98322b197addd5a9c55385121b5d5a4bf363fc5ed8810faeeb1903301f9bfb68",
+      "delivery_sha256": "436f4f7c80155474d5ce0b9282ef2a0e2f9d4890b557fbb01e9e8d4194039754",
       "revised_on": "2026-11-02"
     },
     {
@@ -268,6 +268,17 @@
       "source_sha256": "e750345f581a4854d2cdd2555000d21ae34a5b49c8593a219a56ef6cfb413eff",
       "delivery_sha256": "e750345f581a4854d2cdd2555000d21ae34a5b49c8593a219a56ef6cfb413eff",
       "revised_on": "2026-11-02"
+    },
+    {
+      "project": "bot-28",
+      "source_path": "docs/总后台/独立活动/净亏邀请记录-中文字段与名单展示.md",
+      "delivery_path": "bot-28/后台/独立活动/净亏邀请记录-中文字段与名单展示.md",
+      "role": "后台",
+      "kind": "正文",
+      "git_status": "??",
+      "source_sha256": "37a1603e10b58e1f4fd45dcb3a9a919e46aac1a72c87c8623c0fba2e214c6222",
+      "delivery_sha256": "37a1603e10b58e1f4fd45dcb3a9a919e46aac1a72c87c8623c0fba2e214c6222",
+      "revised_on": "2026-11-02"
     }
   ],
   "excluded": [
@@ -412,6 +423,12 @@
       "project": "bot-28 / melbet_sport-api",
       "branch": "master / dev",
       "description": "会员券包与管理后台净亏邀请接入文档移出后台派奖、输赢及有效投注计算,券包文档可单独发送;移除不必要的后台引用,更新两端前端目录及交付包内引用路径,核对五份来源与副本校验值。"
+    },
+    {
+      "date": "2026-11-02",
+      "project": "bot-28",
+      "branch": "master",
+      "description": "新增管理后台净亏邀请记录字段中文与名单展示说明;申请、发奖记录增加邀请人及被邀请人的ID、账号和实际获券档位/张数,券记录增加账号字段;同步交付副本、目录与校验值。"
     }
   ]
 }

+ 1 - 0
docs/前端交付/交付清单.md

@@ -8,6 +8,7 @@
 
 | 项目 | 来源路径 | 状态 | 交付文件 | 类型 |
 |---|---|---|---|---|
+| bot-28 | `docs/总后台/独立活动/净亏邀请记录-中文字段与名单展示.md` | 新增(2026-11-02) | [净亏邀请记录-中文字段与名单展示](bot-28/后台/独立活动/净亏邀请记录-中文字段与名单展示.md) | 正文 |
 | bot-28 | `docs/总后台/后台实时推送-前端接入.md` | ??(2026-10-31 增补) | [后台实时推送-前端接入.md](bot-28/后台/后台实时推送-前端接入.md) | 正文 |
 | bot-28 | `docs/总后台/独立活动-后台接口.md` | M | [独立活动-后台接口.md](bot-28/后台/独立活动-后台接口.md) | 目录 |
 | bot-28 | `docs/总后台/独立活动/净亏邀请-后台接口.md` | M | [净亏邀请-后台接口.md](bot-28/后台/独立活动/净亏邀请-后台接口.md) | 正文 |

+ 2 - 0
docs/总后台/独立活动-后台接口.md

@@ -11,3 +11,5 @@
 本页仅保留导航;每个活动的参数、示例、上下架、资格和发奖说明均在各自文档内。
 
 新增按钮与新增类型下拉使用 `GET /admin/promotion/options` 返回的 `create_types`,当前只有首充奖励(`value=8`)。`types` 仍提供三种类型用于筛选。固定活动从列表读取现有 `id` 后编辑保存;净亏邀请改为单页平铺表单,直接用 `GET /admin/promotion/detail?type=7` 读取,不再经过列表。
+
+申请记录、发奖记录的中文标签、被邀请人账号及获券列表接入见[净亏邀请记录-中文字段与名单展示](独立活动/净亏邀请记录-中文字段与名单展示.md)。

+ 193 - 0
docs/总后台/独立活动/净亏邀请记录-中文字段与名单展示.md

@@ -0,0 +1,193 @@
+# 净亏邀请记录:中文字段、被邀请人及获券展示
+
+面向**管理后台前端**,用于改造“申请记录”“发奖记录”和“已发优惠券”列表及详情。本次沿用现有接口,增加名单、账号和获券展示数据;不补发历史奖励。
+
+## 1. 现有截图中的三个人在哪里
+
+截图发奖记录 `id=3`、领奖会员 `user_id=164`、关联申请 `account_id=11865`。其规则中有 `qualified_invitees=[168,169,170]`,这就是发奖时达标的三名会员。
+
+这条历史记录的规则未指定优惠券模式,按兼容规则属于**现金奖励**,并有奖励入账流水 `credited_log_id=20800`。500 是邀请人 164 的现金奖励。三名被邀请人用于满足领取条件,这条记录没有给他们发券。
+
+更新接口后,前端直接使用新增的 `inviter`、`invitees` 显示 ID、账号和各自获券,无需再从规则快照解析名单或逐人查询账号。
+
+## 2. 请求和返回位置
+
+在 bot-28 后台 API 主机请求,沿用管理员请求头 `Authorization: Bearer <token>`。
+
+| 页面 | 请求 |
+| --- | --- |
+| 申请记录 | `GET /admin/promotion/records?activity_id=1&kind=accounts&page=1&limit=20` |
+| 发奖记录 | `GET /admin/promotion/records?activity_id=1&kind=rewards&page=1&limit=20` |
+| 已发优惠券 | `GET /admin/promotion/records?activity_id=1&kind=coupons&page=1&limit=20` |
+
+成功 `code=0`,列表位于 `data.data`,总记录数位于 `data.total`;失败展示 `msg`。可传 `user_id`:申请、发奖列表筛选该邀请人;已发券列表匹配持券人或邀请人。`limit` 最大 100。
+
+只有已发优惠券列表支持 `status=unused/used` 筛选。申请、发奖列表暂不提供奖励状态筛选参数。
+
+下述申请、发奖列表新增字段用于净亏邀请(活动类型 `7`),不改变原分页、筛选和原有字段。其他活动类型不增加这组名单字段。
+
+## 3. 新增字段及列表列名
+
+| 返回字段 | 中文名称 / 用法 |
+| --- | --- |
+| `reward_mode` | 奖励方式:`cash` 现金、`coupon` 优惠券;尚无规则时为 `null`,显示“—” |
+| `inviter.id` | 邀请人会员 ID;申请列表可标“申请会员 ID”,发奖列表可标“领奖会员 ID” |
+| `inviter.account` | 邀请人账号 |
+| `inviter.coupons` | 邀请人本次获得的券,按满减档位合并 |
+| `invitees` | 本次关联的被邀请人列表 |
+| `invitees[].id` | 被邀请人会员 ID |
+| `invitees[].account` | 被邀请人账号 |
+| `invitees[].coupons` | 该被邀请人本次获得的券,按满减档位合并 |
+| `reward_id` | 仅申请记录新增:关联发奖记录 ID;未生成发奖记录时为 `null` |
+| `reward_status` | 仅申请记录新增:关联发奖状态;未生成时为 `null`,其余枚举同发奖记录的 `status` |
+
+建议两张列表都增加以下列,不要把嵌套对象直接渲染成原始 JSON:
+
+| 列名 | 展示示例 |
+| --- | --- |
+| 邀请人(ID / 账号) | `164 / demo164` |
+| 邀请人获得的券 | `满100抵50 × 2张;满200抵100 × 1张` |
+| 被邀请人及获券 | 每人一行,例如 `168 / demo168:满100抵100 × 1张`,其余会员继续列出 |
+| 奖励方式 | 现金 / 优惠券 |
+
+现金记录也返回达标会员的 ID 和账号,但双方 `coupons=[]`,显示“本次未发券”。不要把三名达标会员渲染成三笔现金收款记录。
+
+尚未发奖的申请返回 `reward_id=null`、`reward_status=null`、`invitees=[]`,显示“尚未生成发奖名单”。这不表示没有下级,也不表示当前有效邀请人数为 0;这里展示的是与发奖记录关联的历史名单,不是实时邀请进度。
+
+账号取当前可查询的会员账号,不是发奖时的账号快照。账号未设置或会员记录已不存在时,ID 仍保留,`account=null`,显示“—”。这里的会员 ID 对应原记录的 `user_id`,不要当成会员编号 `member_id`。
+
+## 4. 获券数组格式
+
+`inviter.coupons` 和 `invitees[].coupons` 中的每一项:
+
+| 字段 | 中文名称 | 类型 |
+| --- | --- | --- |
+| `min_amount` | 使用门槛 | 金额字符串 |
+| `discount` | 抵扣金额 | 金额字符串 |
+| `quantity` | 本次发放张数 | 整数 |
+
+前端显示“满 `min_amount` 抵 `discount` × `quantity` 张”。金额沿用活动接口的展示格式:整数不带 `.00`,有小数时保留两位。账号按文本展示,保留可能存在的前导零。
+
+数量来自实际发出的券,已使用、已过期的券仍计入**获得张数**。不要把它当成当前可用张数,也不要根据活动当前配置自行生成券档位。没有给被邀请人配置券或该人未获券时,其 `coupons` 为空。
+
+现金记录展示发奖时的达标名单;优惠券记录展示本次计入发券的名单,并包含实际获券人。历史快照中其他达标会员不自动变成本次收券人。
+
+以下为优惠券模式的响应片段,仅演示结构;账号为示例,**不代表截图中的历史现金记录被改成了券奖励**:
+
+```json
+{
+  "code": 0,
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "id": 3003,
+        "activity_id": 1,
+        "user_id": 164,
+        "account_id": 20001,
+        "amount": "500",
+        "status": "paid",
+        "reward_mode": "coupon",
+        "inviter": {
+          "id": 164,
+          "account": "demo164",
+          "coupons": [
+            {"min_amount": "100", "discount": "50", "quantity": 2},
+            {"min_amount": "200", "discount": "100", "quantity": 1}
+          ]
+        },
+        "invitees": [
+          {"id": 168, "account": "demo168", "coupons": [{"min_amount": "100", "discount": "100", "quantity": 1}]},
+          {"id": 169, "account": "demo169", "coupons": [{"min_amount": "100", "discount": "100", "quantity": 1}]},
+          {"id": 170, "account": "demo170", "coupons": [{"min_amount": "100", "discount": "100", "quantity": 1}]}
+        ]
+      }
+    ]
+  }
+}
+```
+
+列表总数仍是申请或发奖记录数,不是被邀请人人数或券张数。每条记录只展示属于这笔发奖的券,不会混入该会员其他发奖记录的券。
+
+## 5. 申请记录:原有英文字段中文名
+
+| 字段 | 中文名称 | 净亏邀请页面建议 |
+| --- | --- | --- |
+| `id` | 申请记录 ID | 显示 |
+| `activity_id` | 活动 ID | 显示或隐藏在活动页上下文中 |
+| `user_id` | 申请会员 ID | 账号使用 `inviter.account` |
+| `round_no` | 参与轮次 | 净亏邀请固定为 1,隐藏 |
+| `spins` | 剩余抽奖次数 | 仅转盘使用,净亏邀请隐藏 |
+| `applied_at` | 申请时间 | 显示;为空时显示“未申请” |
+| `application_snapshot` | 申请时规则 | 不直接显示原始 JSON,按下表展示摘要 |
+| `created_at` | 创建时间 | 显示 |
+| `updated_at` | 更新时间 | 显示 |
+
+`application_snapshot` 当前是 JSON 字符串,展示摘要时先解析;兼容已经解析成对象的数据:
+
+| 解析后的字段 | 中文名称 |
+| --- | --- |
+| `loss` | 申请时净亏 |
+| `from` | 统计开始时间 |
+| `rules.loss_amount` | 申请所需净亏 |
+| `rules.invite_count` | 需邀请有效会员数 |
+| `rules.reward_amount` | 申请时现金奖励金额 / 邀请人券总额上限,按奖励方式显示 |
+| `rules.turnover_ratio` | 解冻投注倍数,仅现金模式需要时展示 |
+
+截图可整理为“申请时净亏 550、申请门槛 500、需邀请 3 人、现金奖励 500”,不要把整段申请规则挤在一行。`application_snapshot.policy`、`application_snapshot.invite_user_cursor` 属于内部核对信息,不作为业务详情展示。
+
+## 6. 发奖记录:原有英文字段中文名
+
+| 字段 | 中文名称 | 展示建议 |
+| --- | --- | --- |
+| `id` | 发奖记录 ID | 显示 |
+| `activity_id` | 活动 ID | 依页面上下文决定是否重复显示 |
+| `user_id` | 领奖会员 ID(邀请人) | 账号使用 `inviter.account` |
+| `account_id` | 关联申请记录 ID | 对应申请记录的 `id` |
+| `round_no` | 发奖轮次 | 净亏邀请固定为 1,隐藏 |
+| `amount` | 现金奖励金额 / 优惠券抵扣总额 | 按本行 `reward_mode` 显示对应列名 |
+| `required_flow` | 解冻所需有效投注额 | 现金模式为 0 时可显示“无需解冻”;券模式隐藏 |
+| `status` | 发奖状态 | 按下表翻译 |
+| `credited_log_id` | 奖励入账流水 ID | 券模式隐藏;无值显示“—” |
+| `unlocked_log_id` | 解冻流水 ID | 无值显示“—” |
+| `rule_snapshot` | 发奖时规则 | 不直接显示原始 JSON;名单和获券使用新增字段 |
+| `flow_snapshot` | 流水统计快照 | 内部字段,隐藏,不展开其中内容 |
+| `unlocked_at` | 解冻时间 | 无值显示“—” |
+| `created_at` | 创建时间 | 不改称“实际发奖时间” |
+| `updated_at` | 更新时间 | 显示 |
+
+| `status` / `reward_status` | 中文 |
+| --- | --- |
+| `reserved` | 待发放 |
+| `locked` | 待解冻(已入冻结余额) |
+| `paid` | 已发放 |
+| `null` | 暂无发奖记录,仅申请记录适用 |
+
+金额为 0 或无需解冻不代表已经发放,仍按状态字段展示。历史记录缺少奖励模式时,接口按现金模式兼容;不要使用活动现在的配置去覆盖旧记录的奖励方式和券档位。
+
+## 7. 已发优惠券列表的账号补充
+
+`kind=coupons` 每条券记录新增 `account`(持券会员账号)、`inviter_account`(邀请人账号),原有字段保留:
+
+| 字段 | 中文名称 |
+| --- | --- |
+| `id` | 券 ID |
+| `reward_id` | 来源发奖记录 ID |
+| `user_id` / `account` | 持券会员 ID / 账号 |
+| `inviter_user_id` / `inviter_account` | 邀请人 ID / 账号 |
+| `role=inviter` | 邀请人奖励券 |
+| `role=invitee` | 被邀请人奖励券 |
+| `min_amount` / `discount` | 使用门槛 / 抵扣金额 |
+| `status=unused` | 未使用;不表示一定在可用时间内 |
+| `status=used` | 已使用 |
+
+发奖列表中的获券摘要使用本行 `inviter.coupons`、`invitees[].coupons` 即可。不要用某一页“已发优惠券”的数据自行拼出获券总数,分页可能只包含部分券。
+
+## 8. 前端验收
+
+- 所有列名使用中文;净亏邀请不展示 `spins`、固定轮次或内部统计快照。
+- 截图历史现金记录显示邀请人 164、三名达标会员及各自账号;双方获券显示“本次未发券”。
+- 优惠券模式同一行显示邀请人及每名被邀请人的 ID、账号、满减档位和获得张数。
+- 未发奖申请显示“尚未生成发奖名单”;账号缺失显示“—”,保留会员 ID。
+- 券使用或过期后,记录页的获得张数保持不变;分页总数仍是主记录数。
+- 更新接口后再启用这些列;历史奖励方式和金额保持原样。

+ 1 - 1
tests/Integration/PromotionActivitiesTest.php

@@ -43,7 +43,7 @@ class PromotionActivitiesTest extends TestCase
         Request::macro('validate', function ($rules) { return app('validator')->make($this->all(), $rules)->validate(); });
         foreach ([
             'activity_rewards' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT,sub_title TEXT,type INTEGER,status INTEGER,start_time INTEGER,end_time INTEGER,lang TEXT,params TEXT,detail_image TEXT,pc_image TEXT,detail_banner TEXT,pc_banner TEXT,content TEXT,created_at TEXT,updated_at TEXT',
-            'users' => 'id INTEGER PRIMARY KEY,member_id TEXT UNIQUE,user_id TEXT,language TEXT,"from" INTEGER,level INTEGER DEFAULT 0,recharge_channel_group_id INTEGER DEFAULT 1,created_at TEXT,updated_at TEXT',
+            'users' => 'id INTEGER PRIMARY KEY,member_id TEXT UNIQUE,user_id TEXT,account TEXT,language TEXT,"from" INTEGER,level INTEGER DEFAULT 0,recharge_channel_group_id INTEGER DEFAULT 1,created_at TEXT,updated_at TEXT',
             'wallets' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT UNIQUE,available_balance TEXT DEFAULT "0",frozen_balance TEXT DEFAULT "0",created_at TEXT,updated_at TEXT',
             'balance_logs' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,type INTEGER DEFAULT 1,amount TEXT,change_type TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,remark TEXT,frozen_status INTEGER,created_at TEXT,updated_at TEXT',
             'share_members' => 'user_id INTEGER PRIMARY KEY,parent_user_id INTEGER,bound_at TEXT',

+ 158 - 0
tests/Integration/PromotionRecordDisplayTest.php

@@ -0,0 +1,158 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Promotion;
+use App\Services\PromotionAdminService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+
+class PromotionRecordDisplayTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh']]));
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        $translator = new \Illuminate\Translation\Translator(new \Illuminate\Translation\ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new \Illuminate\Validation\Factory($translator, $this->app));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager());
+        foreach ([
+            'users' => 'id INTEGER PRIMARY KEY,account TEXT',
+            'promotion_activities' => 'id INTEGER PRIMARY KEY,type INTEGER',
+            'promotion_accounts' => 'id INTEGER PRIMARY KEY,activity_id INTEGER,user_id INTEGER,round_no INTEGER,spins INTEGER,applied_at TEXT,application_snapshot TEXT,created_at TEXT,updated_at TEXT',
+            'promotion_rewards' => 'id INTEGER PRIMARY KEY,activity_id INTEGER,user_id INTEGER,account_id INTEGER,round_no INTEGER,amount TEXT,required_flow TEXT,status TEXT,credited_log_id INTEGER,rule_snapshot TEXT',
+            'promotion_coupons' => 'id INTEGER PRIMARY KEY,reward_id INTEGER,activity_id INTEGER,user_id INTEGER,member_id TEXT,role TEXT,inviter_user_id INTEGER,min_amount TEXT,discount TEXT,categories TEXT,status TEXT,valid_from TEXT,valid_until TEXT',
+        ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
+        foreach ([164, 165, 168, 169, 170, 171] as $id) DB::table('users')->insert(['id' => $id, 'account' => 'member' . $id]);
+        DB::table('promotion_activities')->insert(['id' => 1, 'type' => 7]);
+        DB::table('promotion_accounts')->insert(['id' => 11865, 'activity_id' => 1, 'user_id' => 164, 'round_no' => 1, 'spins' => 0,
+            'applied_at' => '2026-10-29 11:52:42', 'application_snapshot' => json_encode(['rules' => ['reward_amount' => '500']])]);
+        DB::table('promotion_rewards')->insert(['id' => 3, 'activity_id' => 1, 'user_id' => 164, 'account_id' => 11865, 'round_no' => 1,
+            'amount' => '500.00', 'required_flow' => '0.00', 'status' => 'paid', 'credited_log_id' => 20800,
+            'rule_snapshot' => json_encode(['invite_count' => 3, 'reward_amount' => '500', 'qualified_invitees' => [168, 169, 170]])]);
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    private function records(string $kind, array $filters = []): array
+    {
+        $this->app->instance('request', Request::create('/admin/promotion/records', 'GET', $filters + ['activity_id' => 1, 'kind' => $kind]));
+        $body = (new Promotion())->records(new PromotionAdminService())->getData(true);
+        $this->assertSame(0, $body['code'], $body['msg']);
+        return $body['data'];
+    }
+
+    private function coupon(int $id, int $user, string $role, string $min = '100', string $discount = '50', int $reward = 3, string $status = 'unused'): void
+    {
+        DB::table('promotion_coupons')->insert(['id' => $id, 'reward_id' => $reward, 'activity_id' => 1, 'user_id' => $user, 'member_id' => 'wallet-' . $user,
+            'role' => $role, 'inviter_user_id' => 164, 'min_amount' => $min, 'discount' => $discount, 'categories' => '["self:sport"]', 'status' => $status,
+            'valid_from' => '2000-01-01 00:00:00', 'valid_until' => '2001-01-01 00:00:00']);
+    }
+
+    public function test_legacy_cash_reward_exposes_the_three_qualified_members_without_inventing_coupon_awards(): void
+    {
+        DB::table('users')->where('id', 168)->update(['account' => '000168']);
+        $before = DB::table('promotion_rewards')->first();
+        $result = $this->records('rewards', ['user_id' => 164]);
+        $row = $result['data'][0];
+        $this->assertSame(1, $result['total']);
+        $this->assertSame(3, $row['id']);
+        $this->assertSame('cash', $row['reward_mode']);
+        $this->assertSame(['id' => 164, 'account' => 'member164', 'coupons' => []], $row['inviter']);
+        $this->assertSame([168, 169, 170], array_column($row['invitees'], 'id'));
+        $this->assertSame(['000168', 'member169', 'member170'], array_column($row['invitees'], 'account'));
+        $this->assertSame([[], [], []], array_column($row['invitees'], 'coupons'));
+        $this->assertSame('500', $row['amount']);
+        $this->assertSame((array)$before, (array)DB::table('promotion_rewards')->first());
+        $this->assertSame(0, DB::table('promotion_coupons')->count());
+    }
+
+    public function test_coupon_reward_groups_actual_inviter_and_invitee_awards_including_used_and_expired_coupons(): void
+    {
+        DB::table('promotion_rewards')->where('id', 3)->update(['credited_log_id' => null, 'rule_snapshot' => json_encode([
+            'reward_mode' => 'coupon', 'qualified_invitees' => [168, 169, 170, 171], 'coupon_invitees' => [168, 169, 170],
+        ])]);
+        $this->coupon(1, 164, 'inviter'); $this->coupon(2, 164, 'inviter', status: 'used');
+        $this->coupon(3, 164, 'inviter', '200', '100');
+        foreach ([168, 169, 170] as $id) $this->coupon($id, $id, 'invitee', '100', '100');
+        $this->coupon(999, 168, 'invitee', '999', '999', reward: 999); // Another reward must not bleed into this row.
+        DB::table('users')->where('id', 169)->delete();
+        DB::table('users')->where('id', 170)->update(['account' => '']);
+        $before = DB::table('promotion_coupons')->orderBy('id')->get()->toJson();
+        foreach (['rewards', 'accounts'] as $kind) {
+            $row = $this->records($kind)['data'][0];
+            $this->assertSame('coupon', $row['reward_mode']);
+            $this->assertSame([['min_amount' => '100', 'discount' => '50', 'quantity' => 2], ['min_amount' => '200', 'discount' => '100', 'quantity' => 1]], $row['inviter']['coupons']);
+            $this->assertSame([168, 169, 170], array_column($row['invitees'], 'id'));
+            $this->assertSame(['member168', null, null], array_column($row['invitees'], 'account'));
+            foreach ($row['invitees'] as $person) $this->assertSame([['min_amount' => '100', 'discount' => '100', 'quantity' => 1]], $person['coupons']);
+            if ($kind === 'accounts') {
+                $this->assertSame(3, $row['reward_id']);
+                $this->assertSame('paid', $row['reward_status']);
+            }
+        }
+        $this->assertSame($before, DB::table('promotion_coupons')->orderBy('id')->get()->toJson());
+    }
+
+    public function test_application_without_reward_has_no_award_list_and_uses_its_application_mode(): void
+    {
+        DB::table('promotion_accounts')->insert(['id' => 11866, 'activity_id' => 1, 'user_id' => 165,
+            'application_snapshot' => '{"rules":{"reward_mode":"coupon"}}']);
+        $row = $this->records('accounts', ['user_id' => 165])['data'][0];
+        $this->assertNull($row['reward_id']); $this->assertNull($row['reward_status']); $this->assertSame('coupon', $row['reward_mode']);
+        $this->assertSame(['id' => 165, 'account' => 'member165', 'coupons' => []], $row['inviter']);
+        $this->assertSame([], $row['invitees']);
+    }
+
+    public function test_pagination_still_counts_rewards_not_nested_people_and_does_not_mix_reward_snapshots(): void
+    {
+        DB::table('promotion_rewards')->insert(['id' => 4, 'activity_id' => 1, 'user_id' => 164, 'account_id' => 11865, 'round_no' => 2,
+            'amount' => '5', 'status' => 'paid', 'rule_snapshot' => '{"qualified_invitees":[171]}' ]);
+        $first = $this->records('rewards', ['limit' => 1, 'page' => 1]);
+        $second = $this->records('rewards', ['limit' => 1, 'page' => 2]);
+        $this->assertSame(2, $first['total']); $this->assertSame(2, $second['total']);
+        $this->assertSame([171], array_column($first['data'][0]['invitees'], 'id'));
+        $this->assertSame([168, 169, 170], array_column($second['data'][0]['invitees'], 'id'));
+    }
+
+    public function test_cash_history_does_not_require_coupon_tables_and_bad_snapshot_does_not_break_the_list(): void
+    {
+        DB::statement('DROP TABLE bot_promotion_coupons');
+        $this->assertCount(3, $this->records('rewards')['data'][0]['invitees']);
+        DB::table('promotion_rewards')->update(['rule_snapshot' => 'invalid historical snapshot']);
+        $row = $this->records('rewards')['data'][0];
+        $this->assertSame('cash', $row['reward_mode']); $this->assertSame([], $row['invitees']);
+    }
+
+    public function test_coupon_record_list_also_contains_holder_and_inviter_accounts(): void
+    {
+        $this->coupon(1, 168, 'invitee');
+        $row = $this->records('coupons', ['user_id' => 164])['data'][0];
+        $this->assertSame(168, $row['user_id']); $this->assertSame('member168', $row['account']);
+        $this->assertSame(164, $row['inviter_user_id']); $this->assertSame('member164', $row['inviter_account']);
+    }
+
+    public function test_unrelated_activity_types_keep_the_existing_record_shape(): void
+    {
+        DB::table('promotion_activities')->update(['type' => 9]);
+        $row = $this->records('rewards')['data'][0];
+        $this->assertArrayNotHasKey('inviter', $row); $this->assertArrayNotHasKey('invitees', $row);
+    }
+}