doge 12 часов назад
Родитель
Сommit
ec6b5dc78c
28 измененных файлов с 1043 добавлено и 163 удалено
  1. 5 4
      app/Http/Controllers/admin/JisuGameOrder.php
  2. 12 5
      app/Http/Controllers/admin/LhcOrder.php
  3. 10 8
      app/Http/Controllers/admin/Order.php
  4. 4 1
      app/Http/Controllers/admin/Promotion.php
  5. 10 8
      app/Http/Controllers/admin/SportGameOrder.php
  6. 2 2
      app/Models/FundsRecord.php
  7. 14 7
      app/Services/BetService.php
  8. 21 4
      app/Services/PromotionAdminService.php
  9. 42 0
      app/Services/PromotionCouponService.php
  10. 46 4
      app/Services/PromotionService.php
  11. 10 6
      app/Services/Sharing/BetFactSynchronizer.php
  12. 1 1
      app/Support/PromotionAmountFormatter.php
  13. 64 1
      app/Support/PromotionRules.php
  14. 52 0
      database/migrations/2026_11_03_120000_add_promotion_coupons.php
  15. 7 0
      docs/前端交付/README.md
  16. 4 2
      docs/前端交付/bot-28/后台/独立活动-后台接口.md
  17. 152 42
      docs/前端交付/bot-28/后台/独立活动/净亏邀请-后台接口.md
  18. 1 0
      docs/前端交付/melbet_sport-api/前台/README.md
  19. 141 0
      docs/前端交付/melbet_sport-api/前台/独立活动/优惠券券包-会员接口.md
  20. 2 1
      docs/前端交付/melbet_sport-api/前台/独立活动/会员接入.md
  21. 81 13
      docs/前端交付/melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md
  22. 35 10
      docs/前端交付/交付清单.json
  23. 1 0
      docs/前端交付/交付清单.md
  24. 116 0
      docs/后端/净亏邀请优惠券-部署说明.md
  25. 2 2
      docs/总后台/独立活动-后台接口.md
  26. 144 41
      docs/总后台/独立活动/净亏邀请-后台接口.md
  27. 36 1
      tests/Integration/PromotionActivitiesTest.php
  28. 28 0
      tests/Integration/SharingBetFactsTest.php

+ 5 - 4
app/Http/Controllers/admin/JisuGameOrder.php

@@ -122,7 +122,7 @@ class JisuGameOrder extends Controller
                 'id' => ['required', 'array', 'min:1'],
                 'remark' => ['nullable', 'string'],
             ]);
-            $orderList = JisuGameOrderModel::whereIn('id', $params['id'])->get();
+            $orderList = JisuGameOrderModel::whereIn('id', $params['id'])->orderBy('id')->lockForUpdate()->get();
             foreach ($orderList as $order) {
                 if ($order->status != 0) {
                     continue;
@@ -133,10 +133,11 @@ class JisuGameOrder extends Controller
                 $order->save();
 
                 // 获取用户余额
-                $walletInfo = Wallet::where(['member_id' => $order->member_id])->first();
+                $walletInfo = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first();
+                if (!$walletInfo) throw new Exception('退款钱包不存在');
 
                 $before = $walletInfo->available_balance;
-                $after = bcsub($walletInfo->available_balance, $order->amount, 2);
+                $after = bcadd((string)$before, (string)$order->amount, 2);
                 $walletInfo->available_balance = $after;
                 $walletInfo->save();
                 $remark = JisuGameplay::getRemark($order->type);
@@ -160,4 +161,4 @@ class JisuGameOrder extends Controller
 
     }
 
-}
+}

+ 12 - 5
app/Http/Controllers/admin/LhcOrder.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers\admin;
 
+use App\Services\PromotionCouponService;
 use App\Http\Controllers\Controller;
 use App\Models\LhcOrder as LhcOrderModel;
 use App\Models\FundsRecord;
@@ -216,7 +217,7 @@ class LhcOrder extends Controller
             $params = request()->validate([
                 'id' => ['required', 'array', 'min:1'],
             ]);
-            $orderList = LhcOrderModel::whereIn('id', $params['id'])->get();
+            $orderList = LhcOrderModel::whereIn('id', $params['id'])->orderBy('id')->lockForUpdate()->get();
             foreach ($orderList as $order) {
                 if ($order->lottery_status != 0) {
                     continue;
@@ -225,13 +226,19 @@ class LhcOrder extends Controller
                 $order->lottery_status = 4;
                 $order->updated_at = time();
                 $order->save();
+                $gross = $order->total_amount;
+                if ($gross === null) {
+                    $gross = bcmul((string)$order->amount, (string)max(1, $order->items()->count()), 2);
+                }
+                $refund = PromotionCouponService::paid($gross, $order->coupon_discount ?? 0);
+                PromotionCouponService::release('lhc_order', (int) $order->id);
 
                 // 获取用户余额
-                $walletInfo = Wallet::where(['member_id' => $order->member_id])->first();
-                if (!$walletInfo) continue;
+                $walletInfo = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first();
+                if (!$walletInfo) throw new Exception('退款钱包不存在');
                 
                 $before = $walletInfo->available_balance;
-                $after = bcsub($walletInfo->available_balance, $order->amount, 2);
+                $after = bcadd((string)$before, $refund, 2);
                 $walletInfo->available_balance = $after;
                 $walletInfo->save();
                 
@@ -239,7 +246,7 @@ class LhcOrder extends Controller
                 $remark = LhcOrderModel::getRemark($order->type);
                 FundsRecord::addData([
                     'change_type' => $remark.'退款',
-                    'amount' => $order->amount,
+                    'amount' => $refund,
                     'before_balance' => $before,
                     'after_balance' => $after,
                     'member_id' => $order->member_id,

+ 10 - 8
app/Http/Controllers/admin/Order.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers\admin;
 
+use App\Services\PromotionCouponService;
 use App\Http\Controllers\Controller;
 use App\Models\FundsRecord;
 use App\Models\User;
@@ -28,7 +29,7 @@ class Order extends Controller
             $params = request()->validate([
                 'id' => ['required', 'array', 'min:1'],
             ]);
-            $orderList = OrderModel::whereIn('id', $params['id'])->get();
+            $orderList = OrderModel::whereIn('id', $params['id'])->orderBy('id')->lockForUpdate()->get();
             foreach ($orderList as $order) {
                 if ($order->return_status != 0 || $order->pay_status != 1 || $order->settlement_status != 0) {
                     continue;
@@ -38,18 +39,19 @@ class Order extends Controller
                 $order->return_status = 2;
                 $order->return_operation_time = time();
                 $order->save();
-                // 获取用户余额
-                $walletInfo = Wallet::where(['member_id' => $order->user_id])->first();
-                if (!$walletInfo) continue;
-                
+                // A couponed order refunds only what the member paid and returns the coupon.
+                $refund = PromotionCouponService::paid($order->amount, $order->coupon_discount ?? 0);
+                PromotionCouponService::release('sport_order', (int) $order->id);
+                $walletInfo = Wallet::where('member_id', $order->user_id)->lockForUpdate()->first();
+                if (!$walletInfo) throw new Exception('退款钱包不存在');
                 $before = $walletInfo->available_balance;
-                $after = bcsub($walletInfo->available_balance, $order->amount, 2);
+                $after = bcadd((string)$before, $refund, 2);
                 $walletInfo->available_balance = $after;
                 $walletInfo->save();
                 
                 FundsRecord::addData([
                     'change_type' => '体彩退款',
-                    'amount' => $order->amount,
+                    'amount' => $refund,
                     'before_balance' => $before,
                     'after_balance' => $after,
                     'member_id' => $order->user_id,
@@ -215,4 +217,4 @@ class Order extends Controller
         return $this->success($order);
     }
 
-}
+}

+ 4 - 1
app/Http/Controllers/admin/Promotion.php

@@ -23,7 +23,10 @@ class Promotion extends Controller
     public function records(PromotionAdminService $s) { return $this->run(fn () => $s->records(request()->all())); }
     public function detail(PromotionAdminService $s)
     {
-        return $this->run(function () use ($s) { $p = request()->validate(['id' => ['required', 'integer', 'min:1']]); return $s->detail((int)$p['id']); });
+        return $this->run(function () use ($s) {
+            $p = request()->validate(['id' => ['required_without:type', 'integer', 'min:1'], 'type' => ['required_without:id', 'integer', 'in:7,9']]);
+            return $s->detail(isset($p['id']) ? (int)$p['id'] : null, isset($p['type']) ? (int)$p['type'] : null);
+        });
     }
     public function status(PromotionAdminService $s)
     {

+ 10 - 8
app/Http/Controllers/admin/SportGameOrder.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers\admin;
 
+use App\Services\PromotionCouponService;
 use App\Http\Controllers\Controller;
 use App\Models\SportGameOrder as SportGameOrderModel;
 use App\Models\FundsRecord;
@@ -116,7 +117,7 @@ class SportGameOrder extends Controller
                 'id' => ['required', 'array', 'min:1'],
                 'remark' => ['nullable', 'string'],
             ]);
-            $orderList = SportGameOrderModel::whereIn('id', $params['id'])->get();
+            $orderList = SportGameOrderModel::whereIn('id', $params['id'])->orderBy('id')->lockForUpdate()->get();
             foreach ($orderList as $order) {
                 if ($order->status != 0) {
                     continue;
@@ -126,18 +127,19 @@ class SportGameOrder extends Controller
                 $order->updated_at = time();
                 $order->save();
 
-                // 获取用户余额
-                $walletInfo = Wallet::where(['member_id' => $order->member_id])->first();
-                if (!$walletInfo) continue;
-                
+                // A couponed order refunds only what the member paid and returns the coupon.
+                $refund = PromotionCouponService::paid($order->amount, $order->coupon_discount ?? 0);
+                PromotionCouponService::release('sport_game_order', (int) $order->id);
+                $walletInfo = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first();
+                if (!$walletInfo) throw new Exception('退款钱包不存在');
                 $before = $walletInfo->available_balance;
-                $after = bcsub($walletInfo->available_balance, $order->amount, 2);
+                $after = bcadd((string)$before, $refund, 2);
                 $walletInfo->available_balance = $after;
                 $walletInfo->save();
 
                 FundsRecord::addData([
                     'change_type' => '足球游戏退款',
-                    'amount' => $order->amount,
+                    'amount' => $refund,
                     'before_balance' => $before,
                     'after_balance' => $after,
                     'member_id' => $order->member_id,
@@ -154,4 +156,4 @@ class SportGameOrder extends Controller
 
     }
 
-}
+}

+ 2 - 2
app/Models/FundsRecord.php

@@ -3,10 +3,10 @@ namespace App\Models;
 class FundsRecord extends BaseModel
 {
     protected $table = 'balance_logs';
-    protected $fillable = ['id', 'room_id', 'member_id' ,'amount' ,'before_balance' ,'after_balance' ,'change_type','created_at','remark'];
+    protected $fillable = ['id', 'room_id', 'member_id' ,'amount' ,'before_balance' ,'after_balance' ,'change_type','created_at','remark', 'related_id'];
 
     public static function addData($data)
     {
         return self::create($data);
     }
-}
+}

+ 14 - 7
app/Services/BetService.php

@@ -772,6 +772,11 @@ class BetService extends BaseService
         $list = self::findAll(['issue' => $issue_no, 'lottery_status' => self::model()::STATUS_STAY]);
         foreach ($list->toArray() as $k => $v) {
             $profit = $v['amount'];
+            // A couponed slip refunds only what the member paid for all its numbers, and the coupon comes back.
+            if (!empty($v['coupon_id']) || bccomp((string) ($v['coupon_discount'] ?? 0), '0', 2) > 0) {
+                $profit = PromotionCouponService::paid($v['total_amount'], $v['coupon_discount'] ?? 0);
+                PromotionCouponService::release('lhc_order', (int) $v['id']);
+            }
             WalletService::updateBalance($v['member_id'], $profit);
 
             $walletInfo = WalletService::findOne(['member_id' => $v['member_id']]);
@@ -833,6 +838,8 @@ class BetService extends BaseService
             $rebateDate = EffectiveBet::newYorkDate($settlementOrder->getRawOriginal('created_at'));
             foreach($order_item as $value) {
                 $number = $value['number'];
+                // Payout uses the full stake; net result and effective bet use what the member paid after the coupon.
+                $paid = PromotionCouponService::paid($v['amount'], $value['coupon_discount'] ?? 0);
 
                 if (isset($keywordsList[$number])) {
                     $keywordsList[$number] += $v['amount'];
@@ -843,7 +850,7 @@ class BetService extends BaseService
                 $item = [];
                 $item['id'] = $value['id'];
                 $item['lottery_status'] = self::model()::STATUS_LOSS;//未中奖
-                $item['profit_and_loss'] = -$v['amount'];
+                $item['profit_and_loss'] = bcsub('0', $paid, 2);
 
                 if (in_array($number, $awards)) {
                     $amount = $v['amount'];
@@ -871,7 +878,7 @@ class BetService extends BaseService
                     $item['win_amount'] = $profit;
                     $total_amount += $profit;
 
-                    $yl = bcsub($profit, $amount, 2); // 盈利
+                    $yl = bcsub($profit, $paid, 2); // 盈利
                     $item['profit_and_loss'] = $yl;
                     $rebate = Rebate::updateProfit([
                         'member_id' => $v['member_id'],
@@ -879,9 +886,9 @@ class BetService extends BaseService
                         'date' => $rebateDate,
                     ]);
 
-                    RebateService::updateEffectiveBettingAmount($rebate, EffectiveBet::amount($v['amount'], $yl));
+                    RebateService::updateEffectiveBettingAmount($rebate, EffectiveBet::amount($paid, $yl));
                     if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
-                        ActivityUserService::updateBettingAmount($v['member_id'],$v['amount']);
+                        ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                     }
 
                     $memberList[$v['member_id']][] = [
@@ -920,13 +927,13 @@ class BetService extends BaseService
                 } else {
                     $rebate = Rebate::updateProfit([
                         'member_id' => $v['member_id'],
-                        'profit' => ($v['amount'] * -1),
+                        'profit' => bcsub('0', $paid, 2),
                         'date' => $rebateDate,
                     ]);
 
-                    RebateService::updateEffectiveBettingAmount($rebate, EffectiveBet::amount($v['amount'], bcsub('0', (string) $v['amount'], 2)));
+                    RebateService::updateEffectiveBettingAmount($rebate, EffectiveBet::amount($paid, bcsub('0', $paid, 2)));
                     if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
-                        ActivityUserService::updateBettingAmount($v['member_id'],$v['amount']);
+                        ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                     }
                     // 未命中的组合不应用 13/14 特殊赔率。
                     $profit = 0;

+ 21 - 4
app/Services/PromotionAdminService.php

@@ -3,6 +3,7 @@
 namespace App\Services;
 
 use App\Models\PromotionActivity;
+use App\Services\Sharing\ShareDefaults;
 use App\Support\PromotionRules;
 use App\Support\PromotionTranslations;
 use Illuminate\Support\Facades\DB;
@@ -18,7 +19,9 @@ class PromotionAdminService
         return ['types' => $types, 'languages' => [['value' => 'zh', 'label' => '中文'], ['value' => 'en', 'label' => '英文'], ['value' => 'vi', 'label' => '越南文']],
             'create_types' => [['value' => PromotionRules::FIRST_DEPOSIT, 'label' => PromotionRules::types()[PromotionRules::FIRST_DEPOSIT]]],
             'defaults' => [7 => PromotionRules::defaults(7), 8 => PromotionRules::defaults(8), 9 => PromotionRules::defaults(9)],
-            'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS];
+            'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS,
+            'reward_modes' => [['value' => 'cash', 'label' => '现金'], ['value' => 'coupon', 'label' => '优惠券']],
+            'coupon_categories' => array_map(fn ($value) => ['value' => $value, 'label' => ShareDefaults::categories()[$value]], PromotionRules::COUPON_CATEGORIES)];
     }
 
     public function listing(array $input): array
@@ -31,9 +34,13 @@ class PromotionAdminService
         return ['total' => $q->count(), 'data' => $q->orderByDesc('sort')->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()->map(fn ($r) => $this->format($r))->all()];
     }
 
-    public function detail(int $id): array
+    /** Fixed activities (loss invite, wheel) have one row, so the flat edit form can load it by type alone. */
+    public function detail(?int $id, ?int $type = null): array
     {
-        $row = PromotionActivity::find($id);
+        if ($id === null && !in_array($type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) {
+            throw ValidationException::withMessages(['type' => '仅净亏邀请和转盘活动可按type读取']);
+        }
+        $row = $id !== null ? PromotionActivity::find($id) : PromotionActivity::where('type', $type)->orderBy('id')->first();
         if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
         return $this->format($row);
     }
@@ -146,7 +153,17 @@ class PromotionAdminService
     public function records(array $input): array
     {
         $input = Validator::make($input, ['activity_id' => ['required', 'integer', 'min:1'], 'user_id' => ['sometimes', 'integer', 'min:1'],
-            'kind' => ['required', 'in:accounts,rounds,tasks,spins,rewards'], 'page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100']])->validate();
+            'kind' => ['required', 'in:accounts,rounds,tasks,spins,rewards,coupons'], 'status' => ['sometimes', 'in:unused,used'],
+            'page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100']])->validate();
+        if ($input['kind'] === 'coupons') {
+            // Invitee coupons belong to the invitee, so they are found by activity rather than by the inviter's account.
+            $q = DB::table('promotion_coupons')->where('activity_id', $input['activity_id']);
+            if (isset($input['user_id'])) $q->where(fn ($q) => $q->where('user_id', $input['user_id'])->orWhere('inviter_user_id', $input['user_id']));
+            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; });
+            return ['total' => $q->count(), 'data' => $rows];
+        }
         $q = DB::table('promotion_accounts as a')->where('a.activity_id', $input['activity_id']);
         if (isset($input['user_id'])) $q->where('a.user_id', $input['user_id']);
         if ($input['kind'] === 'accounts') $q->select('a.*')->orderByDesc('a.id');

+ 42 - 0
app/Services/PromotionCouponService.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * Backend side of F1 own-game coupons. Coupons are issued and redeemed by melbet_sport-api;
+ * here refunds return the paid amount and give the coupon back.
+ */
+class PromotionCouponService
+{
+    /** What the member actually paid for an order: stake minus its coupon share. */
+    public static function paid($stake, $discount): string
+    {
+        return bcsub(bcadd((string)$stake, '0', 2), bcadd((string)($discount ?? '0'), '0', 2), 2);
+    }
+
+    /** The coupon returns once every order sharing it is refunded; an expired coupon simply stays unusable. */
+    public static function release(string $source, int $orderId): void
+    {
+        if (!Schema::hasTable('promotion_coupon_uses')) return;
+        DB::transaction(function () use ($source, $orderId) {
+            // This immutable link identifies the coupon; always lock the coupon before any use row.
+            $link = DB::table('promotion_coupon_uses')->where('source', $source)->where('order_id', $orderId)->first();
+            if (!$link) return;
+            $coupon = DB::table('promotion_coupons')->where('id', $link->coupon_id)->lockForUpdate()->first();
+            if (!$coupon) throw new \RuntimeException('退款优惠券记录不存在');
+            $use = DB::table('promotion_coupon_uses')->where('id', $link->id)->whereNull('refunded_at')->lockForUpdate()->first();
+            if (!$use) return;
+            $now = PromotionFacts::now();
+            DB::table('promotion_coupon_uses')->where('id', $use->id)->update(['refunded_at' => $now, 'updated_at' => $now]);
+            // Current read: an outer REPEATABLE READ transaction may already have an older snapshot.
+            $remaining = DB::table('promotion_coupon_uses')->where('coupon_id', $use->coupon_id)
+                ->whereNull('refunded_at')->lockForUpdate()->get(['id']);
+            if ($remaining->isNotEmpty()) return;
+            DB::table('promotion_coupons')->where('id', $use->coupon_id)->where('status', 'used')
+                ->update(['status' => 'unused', 'used_source' => null, 'used_stake' => null, 'used_at' => null, 'updated_at' => $now]);
+        });
+    }
+}

+ 46 - 4
app/Services/PromotionService.php

@@ -237,7 +237,8 @@ class PromotionService
         $this->capacity($activity, $member, $amount);
         $received = DB::table('promotion_rewards')->where('activity_id', $activity->id)->where('user_id', $member->id)->exists();
         DB::table('promotion_rewards')->insert(['activity_id' => $activity->id, 'user_id' => $member->id, 'account_id' => $account->id,
-            'round_no' => $roundNo, 'amount' => $amount, 'required_flow' => bcmul($amount, $rules['turnover_ratio'], 2),
+            'round_no' => $roundNo, 'amount' => $amount,
+            'required_flow' => ($rules['reward_mode'] ?? 'cash') === 'coupon' ? '0.00' : bcmul($amount, $rules['turnover_ratio'], 2),
             'status' => 'reserved', 'rule_snapshot' => json_encode($rules), 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
         if (!$received) ++$activity->rewarded_count;
         $activity->budget_used = bcadd((string)$activity->budget_used, $amount, 2); $activity->save();
@@ -296,6 +297,30 @@ class PromotionService
         return DB::table('promotion_rewards')->where('id', $reward->id)->first();
     }
 
+    /** Coupon rewards credit no money; the reserved face value is consumed by the coupons themselves. */
+    private function issueCoupons(object $reward, User $member): object
+    {
+        if ($reward->status !== 'reserved') return $reward;
+        $rules = $this->json($reward->rule_snapshot); $now = PromotionFacts::now();
+        $holders = [[(int)$member->id, (string)$member->member_id, 'inviter', $rules['inviter_coupons']]];
+        foreach (DB::table('users')->whereIn('id', $rules['coupon_invitees'] ?: [0])->orderBy('id')->get(['id', 'member_id']) as $child) {
+            $holders[] = [(int)$child->id, (string)$child->member_id, 'invitee', $rules['invitee_coupons']];
+        }
+        $rows = [];
+        foreach ($holders as [$userId, $memberId, $role, $tiers]) {
+            $seq = 0;
+            foreach ($tiers as $tier) for ($i = 0; $i < $tier['quantity']; $i++) {
+                $rows[] = ['activity_id' => $reward->activity_id, 'reward_id' => $reward->id, 'user_id' => $userId, 'member_id' => $memberId,
+                    'role' => $role, 'inviter_user_id' => $member->id, 'seq' => ++$seq, 'min_amount' => $tier['min_amount'], 'discount' => $tier['discount'],
+                    'categories' => json_encode($rules['coupon_categories']), 'valid_from' => $rules['coupon_valid_from'], 'valid_until' => $rules['coupon_valid_until'],
+                    'status' => 'unused', 'created_at' => $now, 'updated_at' => $now];
+            }
+        }
+        foreach (array_chunk($rows, 500) as $chunk) DB::table('promotion_coupons')->insertOrIgnore($chunk);
+        DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'updated_at' => $now]);
+        return DB::table('promotion_rewards')->where('id', $reward->id)->first();
+    }
+
     public function claim(int $activityId, int $userId, int $roundNo, ?string $platform = null): array
     {
         return $this->mutate($activityId, $userId, function ($activity, $member, $wallet) use ($roundNo, $platform) {
@@ -308,13 +333,18 @@ class PromotionService
                 $snapshot = $this->json($account->application_snapshot); $rules = $snapshot['rules'];
                 $qualified = $this->lossInvitees($member, $account);
                 if (count($qualified) < $rules['invite_count']) $this->invalid('有效邀请人数不足');
-                $reward ??= $this->reserve($activity, $member, $account, 1, $rules['reward_amount'], $rules + ['qualified_invitees' => $qualified]);
+                if (($rules['reward_mode'] ?? 'cash') === 'coupon') {
+                    // Exactly the invitees counted toward the target receive invitee coupons, earliest registrations first.
+                    sort($qualified); $counted = array_slice($qualified, 0, $rules['invite_count']);
+                    $amount = bcadd(Rules::faceValue($rules['inviter_coupons']), bcmul(Rules::faceValue($rules['invitee_coupons']), (string)count($counted), 2), 2);
+                    $reward ??= $this->reserve($activity, $member, $account, 1, $amount, $rules + ['qualified_invitees' => $qualified, 'coupon_invitees' => $counted]);
+                } else $reward ??= $this->reserve($activity, $member, $account, 1, $rules['reward_amount'], $rules + ['qualified_invitees' => $qualified]);
             } elseif ((int)$activity->type === Rules::WHEEL) {
                 // A reserved, earned reward remains payable after the campaign is unpublished.
                 $round = $this->round($account);
                 if (!$reward || !$round || $round->round_no !== $roundNo || $round->status !== 'reached') $this->invalid('本轮尚未达到666');
             } else $this->invalid('首充奖励由充值成功后自动发放');
-            $reward = $this->pay($reward, $member, $wallet);
+            $reward = ($this->json($reward->rule_snapshot)['reward_mode'] ?? 'cash') === 'coupon' ? $this->issueCoupons($reward, $member) : $this->pay($reward, $member, $wallet);
             if ((int)$activity->type === Rules::WHEEL) {
                 $flags = array_intersect_key($this->json($round->completed_tasks), array_flip(Rules::DAILY));
                 DB::table('promotion_rounds')->where('id', $round->id)->update(['status' => 'claimed', 'claimed_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
@@ -388,12 +418,24 @@ class PromotionService
         }
         $result['rewards'] = $account ? DB::table('promotion_rewards')->where('account_id', $account->id)->orderByDesc('round_no')->limit(10)->get()->map(fn ($r) => (array)$r)->all() : [];
         if ($type === Rules::LOSS) {
-            if ($account?->applied_at) $result['rules'] = $this->json($account->application_snapshot)['rules'];
+            // Applications made before coupon mode existed snapshot cash rules without the coupon keys.
+            if ($account?->applied_at) $result['rules'] = array_replace(Rules::defaults(Rules::LOSS), $this->json($account->application_snapshot)['rules']);
+            $result['coupons'] = $this->couponSummary((int)$activity->id, (int)$member->id);
             $result['status'] = $result['rewards'] ? 'claimed' : (!$account?->applied_at ? 'not_applied' : ($result['qualified_invitees'] >= $result['rules']['invite_count'] ? 'ready' : 'collecting'));
         }
         return $result;
     }
 
+    /** The member's own coupons from this activity, grouped by tier. */
+    private function couponSummary(int $activityId, int $userId): array
+    {
+        return DB::table('promotion_coupons')->where('activity_id', $activityId)->where('user_id', $userId)
+            ->selectRaw("role, min_amount, discount, valid_from, valid_until, count(*) as quantity, sum(case when status = 'unused' then 1 else 0 end) as unused")
+            ->groupBy('role', 'min_amount', 'discount', 'valid_from', 'valid_until')->orderBy('role')->orderBy('min_amount')->get()
+            ->map(fn ($r) => ['role' => $r->role, 'min_amount' => (string)$r->min_amount, 'discount' => (string)$r->discount, 'valid_from' => $r->valid_from,
+                'valid_until' => $r->valid_until, 'quantity' => (int)$r->quantity, 'unused' => (int)$r->unused])->all();
+    }
+
     private function wheelToday(PromotionActivity $activity, User $member, ?object $account, ?object $round, array $flags, array $granted): array
     {
         $rules = $this->rules($activity); $today = substr(PromotionFacts::now(), 0, 10);

+ 10 - 6
app/Services/Sharing/BetFactSynchronizer.php

@@ -143,9 +143,11 @@ class BetFactSynchronizer
         if (isset($order->total_amount) && bccomp((string) $order->total_amount, $total, EffectiveBet::SCALE) !== 0) {
             throw new RuntimeException('Lottery total does not match child stakes');
         }
-        $base['total_bet'] = $total;
+        // Coupon discounts reduce what the member paid; payouts were computed on the full stake.
+        $paidTotal = bcsub($total, EffectiveBet::decimal($order->coupon_discount ?? '0'), EffectiveBet::SCALE);
+        $base['total_bet'] = $paidTotal;
         $refund = $this->refundAmount($order, [self::LOTTERY_NAMES[$type] . '退款', '投注退分']);
-        if (bccomp($refund, '0', EffectiveBet::SCALE) > 0 && bccomp($refund, $total, EffectiveBet::SCALE) !== 0
+        if (bccomp($refund, '0', EffectiveBet::SCALE) > 0 && bccomp($refund, $paidTotal, EffectiveBet::SCALE) !== 0
             && !in_array((int) $order->lottery_status, [3, 4], true)) {
             throw new RuntimeException('Partial or inconsistent lottery refund requires review');
         }
@@ -155,14 +157,15 @@ class BetFactSynchronizer
         foreach ($items as $item) {
             if ((string) ($item->member_id ?? '') !== (string) $order->member_id) throw new RuntimeException('Lottery child member mismatch');
             $void = $parentVoid || in_array((int) $item->lottery_status, [3, 4], true);
+            $paid = bcsub($bet, EffectiveBet::decimal($item->coupon_discount ?? '0'), EffectiveBet::SCALE);
             $settled = !$void && in_array((int) $order->lottery_status, [1, 2], true)
                 && in_array((int) $item->lottery_status, [1, 2], true);
             $base['unsettled'] = $base['unsettled'] || (!$settled && !$void);
-            $profit = $settled ? ((int) $item->lottery_status === 1 ? bcsub('0', $bet, EffectiveBet::SCALE)
-                : bcsub(EffectiveBet::decimal($item->win_amount ?? null), $bet, EffectiveBet::SCALE)) : EffectiveBet::zero();
+            $profit = $settled ? ((int) $item->lottery_status === 1 ? bcsub('0', $paid, EffectiveBet::SCALE)
+                : bcsub(EffectiveBet::decimal($item->win_amount ?? null), $paid, EffectiveBet::SCALE)) : EffectiveBet::zero();
             $base['facts'][] = $this->fact('lhc_order_item', (string) $item->id, $user, 'self:lottery', $betAt,
                 $settled || $void ? $this->time($item->updated_at ?? $order->updated_at ?? null) : null,
-                $bet, $profit, $settled, $void, $this->nativeCurrency());
+                $paid, $profit, $settled, $void, $this->nativeCurrency());
         }
         return $base;
     }
@@ -194,7 +197,8 @@ class BetFactSynchronizer
             if ($currency === '') throw new RuntimeException('Third-party currency is missing');
         } else {
             $category = 'self:sport';
-            $bet = EffectiveBet::decimal($order->amount);
+            // The member paid the stake minus any coupon discount; payouts were computed on the full stake.
+            $bet = bcsub(EffectiveBet::decimal($order->amount), EffectiveBet::decimal($order->coupon_discount ?? '0'), EffectiveBet::SCALE);
             $betAt = $this->time($table === 'sport_order' ? ($order->create_time ?? null) : ($order->created_at ?? null));
             if ($table === 'sport_order') {
                 $void = (int) $order->status === 2 || (int) ($order->return_status ?? 0) === 2

+ 1 - 1
app/Support/PromotionAmountFormatter.php

@@ -8,7 +8,7 @@ final class PromotionAmountFormatter
     private const MONEY_FIELDS = ['amount', 'required_flow', 'balance', 'available_balance', 'frozen_balance',
         'before_balance', 'after_balance', 'budget', 'budget_used', 'deposit_min', 'deposit_max', 'deposit_total',
         'loss_amount', 'reward_amount', 'min_deposit', 'min_effective_bet', 'deposit_amount', 'team_deposit_amount',
-        'bet_amount', 'team_bet_amount', 'loss', 'coins', 'remaining'];
+        'bet_amount', 'team_bet_amount', 'loss', 'coins', 'remaining', 'min_amount', 'discount', 'used_stake', 'face_value'];
     private const MONEY_TASKS = ['deposit', 'team_deposit', 'bet', 'team_bet'];
 
     public static function amount($value)

+ 64 - 1
app/Support/PromotionRules.php

@@ -3,6 +3,7 @@
 namespace App\Support;
 
 use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
 
 final class PromotionRules
 {
@@ -12,6 +13,9 @@ final class PromotionRules
     public const TASKS = ['online', 'login', 'invite', 'deposit', 'team_deposit', 'bet', 'team_bet'];
     public const DAILY = ['online', 'login'];
     public const PRIZES = ['0-666', '100', '50', '200', '350', '500', '150', '366'];
+    public const REWARD_MODES = ['cash', 'coupon'];
+    public const COUPON_CATEGORIES = ['self:sport', 'self:lottery'];
+    private const COUPON_KEYS = ['reward_mode', 'coupon_valid_from', 'coupon_valid_until', 'coupon_categories', 'inviter_coupons', 'invitee_coupons'];
 
     public static function supports(int $type): bool { return in_array($type, [7, 8, 9], true); }
     public static function types(): array { return [7 => '净亏邀请奖励', 8 => '首充奖励', 9 => '转盘活动']; }
@@ -20,7 +24,8 @@ final class PromotionRules
     {
         $common = ['turnover_ratio' => '0.00'];
         return $common + match ($type) {
-            7 => ['loss_amount' => '500.00', 'invite_count' => 3, 'reward_amount' => '500.00'],
+            7 => ['loss_amount' => '500.00', 'invite_count' => 3, 'reward_amount' => '500.00', 'reward_mode' => 'cash',
+                'coupon_valid_from' => null, 'coupon_valid_until' => null, 'coupon_categories' => [], 'inviter_coupons' => [], 'invitee_coupons' => []],
             8 => ['min_deposit' => '100.00', 'reward_amount' => '10.00'],
             9 => ['completion_probability' => '20.00', 'online_seconds' => 3600,
                 'deposit_amount' => '3000.00', 'team_deposit_amount' => '500.00',
@@ -34,6 +39,8 @@ final class PromotionRules
         $defaults = self::defaults($type);
         Validator::make(['params' => $input], ['params' => ['array:' . implode(',', array_keys($defaults))]])->validate();
         $data = array_replace($defaults, $input);
+        $coupon = array_intersect_key($data, array_flip(self::COUPON_KEYS));
+        $data = array_diff_key($data, $coupon);
         $rules = [];
         foreach ($data as $key => $value) {
             $rules[$key] = match ($key) {
@@ -46,9 +53,65 @@ final class PromotionRules
         }
         Validator::make($data, $rules)->validate();
         foreach ($data as $key => $value) $data[$key] = in_array($key, ['invite_count', 'online_seconds'], true) ? (int)$value : bcadd((string)$value, '0', 2);
+        return $coupon ? $data + self::coupons($coupon, $data['reward_amount']) : $data;
+    }
+
+    /** Coupon settings stay well-formed in cash mode so switching modes keeps them; completeness is enforced only in coupon mode. */
+    private static function coupons(array $input, string $rewardAmount): array
+    {
+        $invalid = fn (string $message) => throw ValidationException::withMessages(['params' => $message]);
+        $mode = $input['reward_mode'];
+        if (!is_string($mode) || !in_array($mode, self::REWARD_MODES, true)) $invalid('reward_mode参数错误');
+        $data = ['reward_mode' => $mode];
+        foreach (['coupon_valid_from', 'coupon_valid_until'] as $key) {
+            $value = $input[$key];
+            if ($value === null || $value === '') { $data[$key] = null; continue; }
+            $time = is_string($value) ? \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value) : false;
+            if (!$time || $time->format('Y-m-d H:i:s') !== $value) $invalid($key . '参数错误');
+            $data[$key] = $value;
+        }
+        $categories = $input['coupon_categories'];
+        if (!is_array($categories) || array_diff($categories, self::COUPON_CATEGORIES) || count(array_unique($categories)) !== count($categories)) {
+            $invalid('coupon_categories参数错误');
+        }
+        $data['coupon_categories'] = array_values(array_intersect(self::COUPON_CATEGORIES, $categories));
+        foreach (['inviter_coupons', 'invitee_coupons'] as $key) {
+            $tiers = $input[$key];
+            if (!is_array($tiers) || array_values($tiers) !== $tiers || count($tiers) > 10) $invalid($key . '参数错误');
+            $data[$key] = [];
+            foreach ($tiers as $tier) {
+                if (!is_array($tier) || array_diff(array_keys($tier), ['min_amount', 'discount', 'quantity']) || count($tier) !== 3) $invalid($key . '参数错误');
+                foreach (['min_amount', 'discount'] as $field) {
+                    if (!is_numeric($tier[$field]) || !preg_match('/^\d{1,12}(\.\d{1,2})?$/', (string)$tier[$field])) $invalid($key . '参数错误');
+                }
+                $quantity = is_scalar($tier['quantity']) ? filter_var($tier['quantity'], FILTER_VALIDATE_INT) : false;
+                $min = bcadd((string)$tier['min_amount'], '0', 2); $discount = bcadd((string)$tier['discount'], '0', 2);
+                if (bccomp($discount, '0', 2) <= 0 || bccomp($min, $discount, 2) < 0 || $quantity === false || $quantity < 1 || $quantity > 100) {
+                    $invalid($key . '参数错误:抵扣金额须大于0且不高于使用门槛,份数为1至100');
+                }
+                $data[$key][] = ['min_amount' => $min, 'discount' => $discount, 'quantity' => $quantity];
+            }
+        }
+        if ($data['coupon_valid_from'] !== null && $data['coupon_valid_until'] !== null && $data['coupon_valid_until'] <= $data['coupon_valid_from']) {
+            $invalid('优惠券结束时间必须晚于开始时间');
+        }
+        if ($mode === 'coupon') {
+            if (!$data['inviter_coupons']) $invalid('请至少配置一档邀请人优惠券');
+            if (bccomp(self::faceValue($data['inviter_coupons']), $rewardAmount, 2) > 0) $invalid('优惠券抵扣总额不能超过奖励金额');
+            if ($data['coupon_valid_from'] === null || $data['coupon_valid_until'] === null) $invalid('请填写优惠券可用时间');
+            if (!$data['coupon_categories']) $invalid('请选择优惠券可用游戏类型');
+        }
         return $data;
     }
 
+    /** Total discount of one coupon set: sum of discount × quantity. */
+    public static function faceValue(array $tiers): string
+    {
+        $total = '0.00';
+        foreach ($tiers as $tier) $total = bcadd($total, bcmul((string)$tier['discount'], (string)$tier['quantity'], 2), 2);
+        return $total;
+    }
+
     public static function draw(int $coins, bool $complete, string $probability, ?callable $random = null): array
     {
         $random ??= fn (int $min, int $max) => random_int($min, $max);

+ 52 - 0
database/migrations/2026_11_03_120000_add_promotion_coupons.php

@@ -0,0 +1,52 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    /** F1 own-game order tables that accept a coupon; lottery parents and items both carry their share. */
+    private const ORDER_TABLES = ['sport_order', 'sport_game_order', 'lhc_order', 'lhc_order_item'];
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('promotion_coupons')) {
+            Schema::create('promotion_coupons', function (Blueprint $t) {
+                $t->id(); $t->unsignedBigInteger('activity_id'); $t->unsignedBigInteger('reward_id');
+                $t->unsignedBigInteger('user_id'); $t->string('member_id', 64); $t->string('role', 16);
+                $t->unsignedBigInteger('inviter_user_id'); $t->unsignedInteger('seq');
+                $t->decimal('min_amount', 24, 2); $t->decimal('discount', 24, 2); $t->json('categories');
+                $t->dateTime('valid_from'); $t->dateTime('valid_until'); $t->string('status', 16)->default('unused');
+                $t->string('used_source', 32)->nullable(); $t->decimal('used_stake', 24, 2)->nullable(); $t->dateTime('used_at')->nullable();
+                $t->unique(['reward_id', 'user_id', 'role', 'seq'], 'promotion_coupon_issue');
+                $t->index(['member_id', 'status', 'valid_until']); $t->index(['activity_id', 'user_id']); $t->timestamps();
+            });
+        }
+        if (!Schema::hasTable('promotion_coupon_uses')) {
+            Schema::create('promotion_coupon_uses', function (Blueprint $t) {
+                $t->id(); $t->unsignedBigInteger('coupon_id'); $t->string('source', 32); $t->unsignedBigInteger('order_id');
+                $t->decimal('discount', 24, 2); $t->dateTime('refunded_at')->nullable();
+                $t->unique(['source', 'order_id']); $t->index('coupon_id'); $t->timestamps();
+            });
+        }
+        foreach (self::ORDER_TABLES as $table) {
+            if (!Schema::hasTable($table)) continue;
+            Schema::table($table, function (Blueprint $t) use ($table) {
+                if (!Schema::hasColumn($table, 'coupon_id')) $t->unsignedBigInteger('coupon_id')->nullable();
+                if (!Schema::hasColumn($table, 'coupon_discount')) $t->decimal('coupon_discount', 14, 2)->default(0);
+            });
+        }
+    }
+
+    public function down(): void
+    {
+        foreach (self::ORDER_TABLES as $table) {
+            if (!Schema::hasTable($table)) continue;
+            Schema::table($table, function (Blueprint $t) use ($table) {
+                foreach (['coupon_id', 'coupon_discount'] as $column) if (Schema::hasColumn($table, $column)) $t->dropColumn($column);
+            });
+        }
+        Schema::dropIfExists('promotion_coupon_uses');
+        Schema::dropIfExists('promotion_coupons');
+    }
+};

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

@@ -7,6 +7,13 @@
 - [后台实时推送-前端接入](bot-28/后台/后台实时推送-前端接入.md):待上线方案,使用 Laravel Echo + Reverb。包含连接参数、频道授权、16 个频道、事件格式、表格更新、断线补发与登录失效处理。
 - 下述原始范围保留;加上本次新增的1份正文,当前共22个来源文件。
 
+## 2026-11-03 增补
+
+- [净亏邀请-后台接口](bot-28/后台/独立活动/净亏邀请-后台接口.md):改为单页平铺表单(`detail?type=7`),新增现金/优惠券模式切换和券档位、可用时间、可用游戏配置。
+- [净亏邀请-会员接口](melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md):优惠券模式的 `rules`、`coupons` 与领奖说明。
+- [优惠券券包-会员接口](melbet_sport-api/前台/独立活动/优惠券券包-会员接口.md):新增,F1 自有游戏内券包(可用/不可用)及下注 `coupon_id`。
+- 加上本次新增的1份正文,当前共23个来源文件。
+
 ## 本次范围
 
 - **bot-28**:从 `c1b4269bc9c45e2610b9078ddb39a1215b0d81aa` 到当前工作区,包含已提交及全部未提交;首次整理时HEAD也是该提交;后续按用户反馈同步修订,修订记录见交付清单JSON。

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

@@ -1,11 +1,13 @@
 # 独立活动后台文档目录
 
-三个活动各自维护完整接入文档,接口和数据独立于原活动列表。活动迁移仅创建业务表,不新增或删除菜单数据;活动菜单和按钮由后台前端自行添加,权限 URI 见各活动文档。
+三个活动各自维护完整接入文档,接口和数据独立于原活动列表。净亏邀请和转盘为固定活动,仅编辑、上架或下架;首充奖励保留新增、编辑及上下架。菜单与按钮由前端配置,权限 URI 见各活动文档。
 
 | 活动 | 独立后台文档 | 对应会员文档 |
 |---|---|---|
-| 净亏邀请 | [净亏邀请-后台接口](独立活动/净亏邀请-后台接口.md) | [净亏邀请-会员接口](../../melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md) |
+| 净亏邀请 | [净亏邀请-后台接口](独立活动/净亏邀请-后台接口.md) | [净亏邀请-会员接口](../../melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md) · [优惠券券包-会员接口](../../melbet_sport-api/前台/独立活动/优惠券券包-会员接口.md) |
 | 首充奖励 | [首充奖励-后台接口](独立活动/首充奖励-后台接口.md) | [首充奖励-会员接口](../../melbet_sport-api/前台/独立活动/首充奖励-会员接口.md) |
 | 转盘活动 | [转盘活动-后台接口](独立活动/转盘活动-后台接口.md) | [转盘活动-会员接口](../../melbet_sport-api/前台/独立活动/转盘活动-会员接口.md) |
 
 本页仅保留导航;每个活动的参数、示例、上下架、资格和发奖说明均在各自文档内。
+
+新增按钮与新增类型下拉使用 `GET /admin/promotion/options` 返回的 `create_types`,当前只有首充奖励(`value=8`)。`types` 仍提供三种类型用于筛选。固定活动从列表读取现有 `id` 后编辑保存;净亏邀请改为单页平铺表单,直接用 `GET /admin/promotion/detail?type=7` 读取,不再经过列表。

+ 152 - 42
docs/前端交付/bot-28/后台/独立活动/净亏邀请-后台接口.md

@@ -1,75 +1,185 @@
 # 净亏邀请-后台接口
 
-业务类型固定为 `7`。本页为该活动的完整后台接入说明。
+本文面向管理后台前端,说明净亏邀请配置表单和记录查询的接入方式。
 
-本活动使用独立的 `promotion_activities` 配置和 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
+业务类型固定为 `7`。这是固定活动,只有一条配置,仅编辑、上架或下架,不提供新增、复制或另存为活动入口。
+
+本活动使用独立的 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
+
+金额以字符串返回,直接使用接口值展示:整数不带小数,小数保留两位;超过两位的小数截去,不四舍五入。例如 `200.00000000 → 200`、`200.12000000 → 200.12`、`200.1 → 200.10`、`200.129 → 200.12`。页面不再统一补齐多位小数。
+
+## 页面形式:单页平铺表单
+
+净亏邀请不再使用"列表 → 编辑"。菜单进入后直接展示一张平铺表单:
+
+1. 进入页面时并行请求 `GET /admin/promotion/options` 和 `GET /admin/promotion/detail?type=7`。
+2. 用详情回填全部输入框,保存的 `id` 取详情返回值,不写死。
+3. 页面底部"保存"调用 `POST /admin/promotion/submit`;上下架可以是表单内的开关,也可以是单独按钮调用 `POST /admin/promotion/status`。
+4. 参与和发奖记录、已发优惠券放在同页的 Tab 或折叠区,按需请求 `GET /admin/promotion/records`。
+
+不再请求 `GET /admin/promotion?type=7` 列表。
 
 ## 配置接口
 
 | 方法 | 路径 | 参数与用途 |
 |---|---|---|
-| GET | `/admin/promotion?type=7` | 仅查看本活动类型;可选 `page/limit/status/title` |
-| GET | `/admin/promotion/options` | 读取 `defaults["7"]` 默认规则 |
-| GET | `/admin/promotion/detail?id=1` | 配置详情,返回 `params` 对象及 `first_published_at` |
-| POST | `/admin/promotion/submit` | 新增或编辑本活动,固定 `type=7` |
+| GET | `/admin/promotion/detail?type=7` | 读取唯一的净亏邀请配置,返回 `id`、`params` 对象及 `first_published_at`;也可用 `id` 读取 |
+| GET | `/admin/promotion/options` | 默认规则 `defaults["7"]`、奖励模式 `reward_modes`、券可用游戏 `coupon_categories` |
+| POST | `/admin/promotion/submit` | 保存,必须带详情返回的 `id`,固定 `type=7` |
 | POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
-| GET | `/admin/promotion/records` | 查看参与或发奖记录 |
+| GET | `/admin/promotion/records` | 查看参与、发奖及已发优惠券 |
+
+`detail` 的 `id` 和 `type` 二选一,`type` 只接受 `7`(净亏邀请)或 `9`(转盘)。不带 `id` 保存会返回 `code=-3`,提示"净亏和转盘活动不支持新增,请编辑现有活动"。
+
+`options` 新增:
+
+```json
+{
+  "reward_modes": [{"value": "cash", "label": "现金"}, {"value": "coupon", "label": "优惠券"}],
+  "coupon_categories": [{"value": "self:sport", "label": "F1体育"}, {"value": "self:lottery", "label": "F1彩票"}]
+}
+```
+
+## 表单字段
+
+### 通用字段(与原来相同)
+
+保存必填根字段:`id/title/type/status/start_time/end_time` 及三语 `translations`。时间按UTC+8填写,结束必须晚于开始。可选 `lang/sort/content/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。
 
-列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。新增省略 `id`,编辑带 `id`;创建后不能改变业务类型。
+可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,只展示不提交。
 
-保存必填根字段:`title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;新增省略的项使用默认值,编辑局部规则保留其余原值。
+净亏邀请没有图片字段,不展示图片上传/预览,也不校验或提交 `image`。
 
-可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。新增默认不限制,编辑省略字段保留原值。
+### 规则 `params`
 
-可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,不能手动改写。
+`params` 传对象,未知键报错;只传部分键时其余保留原值。表单以详情返回的当前值回填,保存时建议整组提交。
 
-## 保存示例
+| 字段 | 默认值 | 显示条件 | 含义 |
+|---|---|---|---|
+| loss_amount | 500.00 | 始终 | 申请所需累计净亏 |
+| invite_count | 3 | 始终 | 申请后需新增的有效直属会员数,1–1000 |
+| reward_amount | 500.00 | 始终 | 现金模式为发放金额;优惠券模式为邀请人券抵扣总额上限 |
+| reward_mode | `cash` | 始终 | 奖励模式切换:`cash` 现金 / `coupon` 优惠券 |
+| turnover_ratio | 0.00 | 仅现金模式 | 奖金所需有效投注倍数;优惠券模式不使用 |
+| coupon_valid_from | null | 仅优惠券模式 | 所有券统一的可用开始时间,`Y-m-d H:i:s`(UTC+8) |
+| coupon_valid_until | null | 仅优惠券模式 | 所有券统一的可用结束时间,须晚于开始 |
+| coupon_categories | [] | 仅优惠券模式 | 可用游戏,多选,取值 `self:sport` / `self:lottery`(只限 F1 自有游戏,第三方不可用) |
+| inviter_coupons | [] | 仅优惠券模式 | 邀请人券档位数组,见下 |
+| invitee_coupons | [] | 仅优惠券模式 | 每名被邀请人券档位数组,见下;可为空 |
+
+券档位每一行三个输入框,可增删行,每组最多10行:
+
+| 字段 | 示例 | 校验 |
+|---|---|---|
+| min_amount | 100 | 使用门槛:单次下注满该金额可用,最多两位小数 |
+| discount | 50 | 抵扣金额,大于0且不高于 `min_amount` |
+| quantity | 2 | 份数,1–100 的整数 |
+
+例如邀请人券配置"满100抵50 × 2、满200抵100 × 1":
+
+```json
+[
+  {"min_amount": "100", "discount": "50", "quantity": 2},
+  {"min_amount": "200", "discount": "100", "quantity": 1}
+]
+```
+
+### 模式切换规则
+
+- 切到 `cash` 时隐藏券相关输入框,但保存时**原样提交**已填写的券配置;服务端保留这些值,切回 `coupon` 时无需重填。现金模式下券配置只校验格式(档位数值合法),不校验必填。
+- 切到 `coupon` 时隐藏 `turnover_ratio`,以下为必填:
+  - `inviter_coupons` 至少一行
+  - `coupon_valid_from`、`coupon_valid_until`
+  - `coupon_categories` 至少选一个
+- 优惠券模式下邀请人券的 `Σ(discount × quantity)` 不得超过 `reward_amount`。页面可实时显示"已配置抵扣总额 / 奖励金额",服务端以同样规则校验。被邀请人券不计入这个上限。
+
+常见错误提示(`code=-3`,直接展示 `msg`):
+
+| msg | 原因 |
+|---|---|
+| 优惠券抵扣总额不能超过奖励金额 | 邀请人券抵扣×份数之和超过 `reward_amount` |
+| 请至少配置一档邀请人优惠券 | 优惠券模式下邀请人券为空 |
+| 请填写优惠券可用时间 | 缺开始或结束时间 |
+| 优惠券结束时间必须晚于开始时间 | 时间顺序错误 |
+| 请选择优惠券可用游戏类型 | 未勾选游戏 |
+| inviter_coupons参数错误:抵扣金额须大于0且不高于使用门槛,份数为1至100 | 某行数值不合法 |
+| reward_mode参数错误 / coupon_categories参数错误 | 取值不在选项内 |
+
+### 保存示例(优惠券模式)
+
+`id` 使用详情返回值,下面的 1 仅为示例。
 
 ```json
 {
+  "id": 1,
   "title": "净亏邀请奖励",
   "type": 7,
-  "status": 0,
-  "start_time": "2026-10-26 00:00:00",
-  "end_time": "2027-10-26 23:59:59",
-  "publish_platforms": [
-    "PC",
-    "H5",
-    "APP"
-  ],
+  "status": 1,
+  "start_time": "2026-11-03 00:00:00",
+  "end_time": "2027-11-03 23:59:59",
+  "publish_platforms": ["PC", "H5", "APP"],
+  "translations": {
+    "zh": {"title": "净亏邀请奖励", "content": "<p>…</p>"},
+    "en": {"title": "Net loss invite", "content": "<p>…</p>"},
+    "vi": {"title": "Mời bạn hoàn lỗ", "content": "<p>…</p>"}
+  },
   "params": {
-    "loss_amount": "500.00",
+    "loss_amount": "500",
     "invite_count": 3,
-    "reward_amount": "500.00",
-    "turnover_ratio": "0.00"
+    "reward_amount": "500",
+    "reward_mode": "coupon",
+    "turnover_ratio": "0",
+    "coupon_valid_from": "2026-11-03 00:00:00",
+    "coupon_valid_until": "2026-12-31 23:59:59",
+    "coupon_categories": ["self:sport", "self:lottery"],
+    "inviter_coupons": [
+      {"min_amount": "100", "discount": "50", "quantity": 4},
+      {"min_amount": "200", "discount": "100", "quantity": 3}
+    ],
+    "invitee_coupons": [
+      {"min_amount": "100", "discount": "50", "quantity": 1},
+      {"min_amount": "200", "discount": "100", "quantity": 1}
+    ]
   }
 }
 ```
 
-## 业务规则与字段
+邀请人券抵扣总额 `50×4 + 100×3 = 500`,等于奖励金额,可以保存。
 
-| params字段 | 默认值 | 含义 |
-|---|---|---|
-| loss_amount | 500.00 | 申请所需累计净亏 |
-| invite_count | 3 | 申请后需新增的有效直属会员数 |
-| reward_amount | 500.00 | 达标后领取奖金 |
-| turnover_ratio | 0.00 | 奖金所需有效投注倍数 |
+## 业务规则
+
+1. 净亏和有效邀请人数直接使用接口返回值。
+2. 达到净亏门槛后会员点击申请;申请成功时固定当时的整套规则(包括奖励模式、券档位、券时间与游戏),之后修改配置不影响已申请的会员。无效申请不消耗机会,重复有效申请返回原进度。
+3. 会员是否达到活动条件由服务端判断,记录页显示对应状态。
+4. 新增有效直属会员达到人数后,会员自行领取。每名会员在本活动只能领取一次,下架再上架不重置机会或首次上架时间。
+5. **现金模式**:记录页显示接口返回的奖励金额和状态。
+6. **优惠券模式**:领取时不发现金,改为发券:
+   - 邀请人按 `inviter_coupons` 每份一张券。
+   - 计入人数的被邀请人每人按 `invitee_coupons` 一套券。如果有效下级多于 `invite_count`,按注册先后取前 `invite_count` 名。
+   - 所有券使用同一可用时间和可用游戏。
+   - 奖励记录 `amount` 为本次发出的券抵扣总额,状态为 `paid`;记录页直接展示,会员钱包不增加现金。
+7. 券只能在 F1 自有游戏(F1体育、F1彩票)下注时使用;退款后的可用状态以券包接口返回为准。
 
-1. 从本活动第一次上架后下单的真实注单累计净亏:`max(0,-已结算且未撤销注单净输赢合计)`,盈亏相抵。充值、提现、赠送和转账不算游戏损益。
-2. 达到净亏门槛后会员点击申请;申请成功固定当时的奖励规则、净亏快照及申请时间。无效申请不消耗机会,重复有效申请返回原进度。
-3. 只计申请后新注册且建立直属关系的下级。必须同时达到系统累计真实充值、累计有效投注双门槛;使用当日已生效政策,系统修改门槛按既有规则次日生效,不写死截图200。
-4. 有效投注逐笔按 `min(本金, |净输赢|)` 计算,只计已结算未撤销的真实注单。人工上分、活动赠送不计真实充值。
-5. 新增有效直属会员达到人数后,会员自行领取。每名会员在本活动只能领取一次,下架再上架不重置机会或首次上架时间。
-6. 奖金按申请时保存的规则发放。`turnover_ratio=0` 直接进入可用余额,大于0进入冻结余额,发放后新增有效投注达到“奖金×倍数”后自动解冻,各笔奖金独立计算。
+## 参与、发奖与优惠券记录
 
-## 参与与发奖记录
+`GET /admin/promotion/records?activity_id=1&kind=accounts&page=1&limit=20` 查看申请记录,`kind=rewards` 查看奖金,可追加会员 ID `user_id`(使用会员列表返回的 `id`)。返回 `total/data`。
 
-`GET /admin/promotion/records?activity_id=1&kind=accounts&page=1&limit=20` 查看申请记录,`kind=rewards` 查看奖金,可追加会员数据库ID `user_id`。返回 `total/data`。
+申请记录包含 `applied_at/application_snapshot`;奖金有 `reserved/locked/paid` 状态和资金流水关联(优惠券模式无流水)。没有有效申请前不允许领奖,重复领取不会重复入账或发券。停止活动使用下架,不删除参与历史。
 
-申请记录包含 `applied_at/application_snapshot`;奖金有 `reserved/locked/paid` 状态和资金流水关联。没有有效申请前不允许领奖,重复领取不会重复入账。停止活动使用下架,不删除参与历史。
+`kind=coupons` 查看已发优惠券,可加 `user_id`(匹配持券人或邀请人)和 `status=unused/used`:
 
-## 权限与上线
+| 字段 | 含义 |
+|---|---|
+| id / activity_id / reward_id | 券ID、活动、来源奖励记录 |
+| user_id / member_id | 持券会员 |
+| role | `inviter` 邀请人券 / `invitee` 被邀请人券 |
+| inviter_user_id | 发起邀请的会员 |
+| min_amount / discount | 使用门槛、抵扣金额 |
+| categories | 可用游戏数组 |
+| valid_from / valid_until | 可用时间 |
+| status | `unused` 未使用 / `used` 已使用 |
+| used_source / used_stake / used_at | 使用的注单类型、下注毛额、使用时间 |
 
-独立活动菜单(`promotion`)和按钮由后台前端自行添加;配置、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。上线执行新增迁移 `2026_10_26_160000_create_promotion_activities.php`,该迁移仅创建活动业务表,不新增或删除菜单数据。计划任务每分钟运行 `promotions:sync`;有效投注沿用已有分享注单同步。
+## 权限
 
-对应会员端文档:[《净亏邀请-会员接口》](../../../melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md)。
+独立活动菜单和按钮由后台前端配置;读取、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`,本次没有新增权限。

+ 1 - 0
docs/前端交付/melbet_sport-api/前台/README.md

@@ -8,6 +8,7 @@
 | [统计装置与活动采集-前台接口](统计装置与活动采集-前台接口.md) | 正文 |
 | [会员接入](独立活动/会员接入.md) | 目录 |
 | [净亏邀请-会员接口](独立活动/净亏邀请-会员接口.md) | 正文 |
+| [优惠券券包-会员接口](独立活动/优惠券券包-会员接口.md) | 正文 |
 | [首充奖励-会员接口](独立活动/首充奖励-会员接口.md) | 正文 |
 | [转盘活动-会员接口](独立活动/转盘活动-会员接口.md) | 正文 |
 | [转盘播报示例](独立活动/转盘播报示例.json) | 示例数据 |

+ 141 - 0
docs/前端交付/melbet_sport-api/前台/独立活动/优惠券券包-会员接口.md

@@ -0,0 +1,141 @@
+# 优惠券券包-会员接口
+
+本文面向会员网站和 APP 前端,包含券包页面与下注用券的接入约定。
+
+优惠券来自净亏邀请活动的优惠券模式:邀请人领奖时获得邀请人券,计入人数的被邀请人同时获得被邀请人券。券只能在 **F1 自有游戏**(F1体育、F1彩票)下注时使用,第三方游戏不可用。
+
+接口在 **melbet_sport-api主机**,与钱包接口使用相同Token:请求头为原始 `Authorization: <token>`,无需Bearer前缀,语言沿用请求头 `lang`。成功 `code=1`,业务错误 `code=0`,Token过期 `code=401`,展示实际 `msg`。券包接口的金额以字符串返回,展示规则同活动接口:整数不带小数,小数保留两位(如 `50`、`37.50`)。
+
+## 页面:游戏内券包
+
+进入 F1 自有游戏(体育下注页、六合彩、PC28/极速28、极速彩票、足球小游戏)后显示券包入口,打开后两个 Tab:
+
+| Tab | 请求 | 内容 |
+|---|---|---|
+| 可用 | `GET /api/coupon/list?tab=usable&category=<当前游戏分类>` | 未使用、在可用时间内、可用于当前游戏的券 |
+| 不可用 | `GET /api/coupon/list?tab=unusable&category=<当前游戏分类>` | 已使用、已过期、未到时间、不适用当前游戏的券 |
+
+Tab 上的数量用 `GET /api/coupon/count?category=<当前游戏分类>`。
+
+当前游戏分类:
+
+| 游戏 | category |
+|---|---|
+| F1体育(体育下注、足球小游戏) | `self:sport` |
+| F1彩票(六合彩、PC28/极速28、极速赛车/时时彩/飞艇/快3等) | `self:lottery` |
+
+不传 `category` 时不按游戏过滤(例如个人中心的总券包):可用 = 未使用且在可用时间内。
+
+## 接口
+
+### GET /api/coupon/list
+
+| 参数 | 必填 | 说明 |
+|---|---|---|
+| tab | 否 | `usable`(默认)/ `unusable` |
+| category | 否 | `self:sport` / `self:lottery` |
+| page | 否 | 默认 1 |
+| limit | 否 | 默认 20,最大 100 |
+
+可用列表按即将到期优先、抵扣金额大优先;不可用列表按发放时间倒序。以下为成功响应中的 `data` 对象:
+
+```json
+{
+  "total": 1,
+  "data": [
+    {
+      "id": 31,
+      "activity_id": 1,
+      "activity_title": "净亏邀请奖励",
+      "role": "inviter",
+      "min_amount": "200",
+      "discount": "100",
+      "categories": ["self:sport", "self:lottery"],
+      "category_names": ["F1体育", "F1彩票"],
+      "valid_from": "2026-11-03 00:00:00",
+      "valid_until": "2026-12-31 23:59:59",
+      "status": "unused",
+      "usable": true,
+      "unusable_reason": null,
+      "used_at": null,
+      "used_stake": null
+    }
+  ]
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| id | 券ID,下注时作为 `coupon_id` 提交 |
+| role | `inviter` 邀请奖励券 / `invitee` 好友奖励券 |
+| min_amount / discount | 显示为"满 min_amount 抵 discount" |
+| categories / category_names | 可用游戏 |
+| valid_from / valid_until | 可用时间(UTC+8) |
+| status | `unused` / `used` |
+| usable | 在本次 `category` 条件下是否可用 |
+| unusable_reason | `used` 已使用、`expired` 已过期、`not_started` 未到可用时间、`category` 不适用当前游戏;可用时为 `null` |
+| used_at / used_stake | 使用时间、使用时的下注金额 |
+
+### GET /api/coupon/count
+
+参数 `category` 同上,成功响应中的 `data` 为 `{"usable": 3, "unusable": 1}`。
+
+## 下注时使用
+
+用户在下注单上从"可用"列表选一张券(下注金额须 ≥ `min_amount`),提交时带上 `coupon_id`。不用券时不传或传空,行为与原来完全一样。
+
+| 下注接口 | coupon_id 位置 | 覆盖范围 |
+|---|---|---|
+| `POST /api/lhcOrder/submit`(六合彩) | 请求根级 `coupon_id` | 整张注单 |
+| `POST /api/game/bet`(PC28/极速28) | 请求根级 `coupon_id` | 整张注单 |
+| `POST /api/jisuGameOrder/submit`(极速彩票) | 请求根级 `coupon_id` | 整张注单 |
+| `POST /api/sportGameOrder/submit`(足球小游戏) | 请求根级 `coupon_id` | 整张注单 |
+| `POST /api/order/submit`(F1体育) | 每一注 `data[i].coupon_id` | 该注 |
+
+规则:
+
+- 一张券只能用一次。根级 `coupon_id` 的接口,一次请求最多用一张券;F1体育每一注可各带一张,同一张券不能用在两注上。
+- 门槛按下注**总额**判断:根级券看整张注单所有号码/投注项金额合计,F1体育看该注金额。
+- 扣款 = 下注总额 − 抵扣金额。下注成功后该券进入"不可用"(`used`)。
+- 券不可用时:
+  - 根级 `coupon_id` 的接口整单失败,不扣款,`msg` 为原因。
+  - F1体育只有该注进入返回的 `failure` 列表(带 `msg`),其他注照常成功。
+
+可能的 `msg`:优惠券不存在、优惠券已使用、优惠券尚未到可用时间、优惠券已过期、该优惠券不能用于当前游戏、投注金额未达到优惠券使用门槛、coupon_id必须为正整数。
+
+请求示例(六合彩,两个号码各 100,用"满200抵100"):
+
+```json
+{
+  "type": 1,
+  "coupon_id": 31,
+  "data": [
+    {"id": 101, "amount": 100},
+    {"id": 102, "amount": 100}
+  ]
+}
+```
+
+F1体育示例(第一注用券,第二注不用):
+
+```json
+{
+  "data": [
+    {"id": 9001, "data_id": 123, "odd": "1.95", "value": "home", "amount": 200, "coupon_id": 31},
+    {"id": 9002, "data_id": 124, "odd": "2.10", "value": "away", "amount": 50}
+  ]
+}
+```
+
+注单列表与详情凡是返回订单整行的,会多出 `coupon_id`(未用券为 `null`)和 `coupon_discount`(本订单抵扣金额,未用券为 `0`)两个字段,可在注单上显示"已用券抵扣 xx"。注单的 `amount`/`total_amount` 仍是下注原额。
+
+## 退款后的页面刷新
+
+退款处理完成后,刷新余额、注单列表、券包列表及券包数量。优惠券的可用状态以重新请求的接口结果为准:已恢复可用的券显示在“可用”,已经过期的券仍显示在“不可用”。
+
+## 获得券的通知
+
+券由服务端在净亏邀请领奖时一次性发放:
+
+- 邀请人:在活动详情的 `coupons` 和券包中看到。
+- 被邀请人:没有单独推送,进入券包即可看到 `role=invitee` 的券。如需提示,可在进入游戏时调用 `GET /api/coupon/count`,`usable>0` 时显示红点。

+ 2 - 1
docs/前端交付/melbet_sport-api/前台/独立活动/会员接入.md

@@ -5,7 +5,8 @@
 - [净亏邀请-会员接口](净亏邀请-会员接口.md)
 - [首充奖励-会员接口](首充奖励-会员接口.md)
 - [转盘活动-会员接口](转盘活动-会员接口.md)
+- [优惠券券包-会员接口](优惠券券包-会员接口.md):净亏邀请优惠券模式发放的券,在 F1 自有游戏内查看和下注使用
 
-[后台文档目录](../../../bot-28/后台/独立活动-后台接口.md) · [转盘播报联调示例,每组50条](转盘播报示例.json)
+[转盘播报联调示例,每组50条](转盘播报示例.json)
 
 上述活动使用melbet_sport-api主机的 `/api/promotion/*` 独立入口,不加入原活动列表。

+ 81 - 13
docs/前端交付/melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md

@@ -4,12 +4,13 @@
 
 本活动独立于原活动列表,通过 **melbet_sport-api主机** 的 `/api/promotion/*` 接口接入,与现有钱包接口使用相同Token。原 `/api/index/activity` 不返回本活动。
 
-请求头为原始 `Authorization: <token>`,无需Bearer前缀。每次传 `platform=PC/H5/APP`;身份由Token识别,不提交会员ID。成功 `code=1`,业务错误 `code=0`,Token过期返回 `code=401`,展示实际 `msg`。所有金额按服务端字符串展示。
+请求头为原始 `Authorization: <token>`,无需Bearer前缀。每次传 `platform=PC/H5/APP`;身份由Token识别,不提交会员ID。成功 `code=1`,业务错误 `code=0`,Token过期返回 `code=401`,展示实际 `msg`。金额以字符串返回,直接使用接口值展示:整数不带小数,小数保留两位;超过两位的小数截去,不四舍五入。例如 `200.00000000 → 200`、`200.12000000 → 200.12`、`200.1 → 200.10`、`200.129 → 200.12`。页面不再统一补齐多位小数。
 
-独立入口调用 `GET /api/promotion/list?type=7&platform=H5`,可加 `lang=zh`,返回 `total/data`。各条包含 `activity_id/type/type_name/title/image/content/rules/is_eligible/ineligible_reason`。不要在原活动类型选项中追加此类型。
+独立入口调用 `GET /api/promotion/list?type=7&platform=H5`,语言沿用全局请求头 `lang: zh/en/vi`,URL和请求体不重复传语言,返回 `total/data`。各条包含 `activity_id/type/type_name/title/content/rules/is_eligible/ineligible_reason`。不要在原活动类型选项中追加此类型。
 
 `GET /api/promotion/detail?activity_id=1&platform=H5` 只读返回本人状态;不要把旧活动ID当成独立活动ID。提交操作始终以服务端校验为准。
 
+
 ## 页面与接口
 
 | 方法 | 路径 | 参数及用途 |
@@ -25,17 +26,84 @@
 
 ```json
 {
-  "activity_id": 1, "type": 7, "round_no": 1,
-  "rules": {"loss_amount":"500.00","invite_count":3,"reward_amount":"500.00","turnover_ratio":"0.00"},
-  "loss": "500.00", "applied_at": "2026-10-26 15:00:00",
-  "qualified_invitees": 2, "status": "collecting",
-  "valid_member_policy": {"id":1,"effective_date":"2026-10-26","min_deposit":"200","min_effective_bet":"300"},
-  "rewards": []
+  "activity_id": 1,
+  "type": 7,
+  "round_no": 1,
+  "rules": {
+    "loss_amount": "500",
+    "invite_count": 3,
+    "reward_amount": "500",
+    "turnover_ratio": "0.00",
+    "reward_mode": "cash",
+    "coupon_valid_from": null,
+    "coupon_valid_until": null,
+    "coupon_categories": [],
+    "inviter_coupons": [],
+    "invitee_coupons": []
+  },
+  "loss": "500",
+  "applied_at": "2026-10-26 15:00:00",
+  "qualified_invitees": 2,
+  "status": "collecting",
+  "valid_member_policy": {
+    "id": 1,
+    "effective_date": "2026-10-26",
+    "min_deposit": "200",
+    "min_effective_bet": "300"
+  },
+  "rewards": [],
+  "coupons": []
 }
 ```
 
 门槛数值仅为示例,有效推广的充值、投注条件使用 `valid_member_policy`,不写死200。
 
+## 奖励模式
+
+`rules.reward_mode` 决定领奖内容,页面按它切换文案:
+
+| reward_mode | 领到什么 | 页面展示 |
+|---|---|---|
+| cash | `reward_amount` 现金,`turnover_ratio>0` 时先进冻结余额 | 展示奖金金额及流水倍数 |
+| coupon | 优惠券,不发现金 | 展示 `inviter_coupons` 券档位、可用时间、可用游戏;可提示"好友达标后也可获得" `invitee_coupons` |
+
+申请成功后 `rules` 为申请当时的快照(含奖励模式和券配置);尚未申请时为当前配置。旧申请的快照没有券字段,服务端会补默认值(`reward_mode=cash`)。
+
+优惠券模式 `rules` 示例:
+
+```json
+{
+  "loss_amount": "500",
+  "invite_count": 3,
+  "reward_amount": "500",
+  "turnover_ratio": "0.00",
+  "reward_mode": "coupon",
+  "coupon_valid_from": "2026-11-03 00:00:00",
+  "coupon_valid_until": "2026-12-31 23:59:59",
+  "coupon_categories": ["self:sport", "self:lottery"],
+  "inviter_coupons": [
+    {"min_amount": "100", "discount": "50", "quantity": 4},
+    {"min_amount": "200", "discount": "100", "quantity": 3}
+  ],
+  "invitee_coupons": [
+    {"min_amount": "100", "discount": "50", "quantity": 1}
+  ]
+}
+```
+
+档位读法:`min_amount=100, discount=50, quantity=4` 显示为"满100抵50 × 4张"。`coupon_categories` 显示名:`self:sport` F1体育、`self:lottery` F1彩票。
+
+`coupons` 为本人从本活动得到的券汇总(领奖前为空数组),按档位分组:
+
+```json
+[
+  {"role": "inviter", "min_amount": "100", "discount": "50", "valid_from": "2026-11-03 00:00:00", "valid_until": "2026-12-31 23:59:59", "quantity": 4, "unused": 3},
+  {"role": "inviter", "min_amount": "200", "discount": "100", "valid_from": "2026-11-03 00:00:00", "valid_until": "2026-12-31 23:59:59", "quantity": 3, "unused": 3}
+]
+```
+
+`quantity` 为发放张数,`unused` 为尚未使用张数。查看和使用单张券走[《优惠券券包-会员接口》](优惠券券包-会员接口.md)。
+
 ## 状态与领奖流程
 
 | status | 含义 | 页面操作 |
@@ -43,14 +111,14 @@
 | not_applied | 尚未成功申请 | 净亏达到 `rules.loss_amount` 后可申请 |
 | collecting | 已申请,继续邀请 | 展示 `qualified_invitees/rules.invite_count` |
 | ready | 有效邀请人数达标 | 传 `round_no=1` 领取 |
-| claimed | 已领取 | 展示奖金记录,关闭重复领取入口 |
+| claimed | 已领取 | 现金模式展示奖金记录;优惠券模式展示 `coupons` 并引导去券包;关闭重复领取入口 |
 
-净亏从本活动第一次上架后下单的已结算、未撤销真实注单累计,盈利与亏损相抵,不是充值减提现。申请后新注册并建立直属关系的下级,累计真实充值和有效投注同时达标,才增加有效人数。有效投注逐笔取 `min(本金, |净输赢|)`;人工上分、赠送不计充值。
+页面直接展示接口返回的净亏、有效邀请人数和达标状态。
 
 每人同一活动只有一次机会。重复申请返回原进度,无效申请不消耗机会;下架再上架不重置。申请成功时固定奖励规则,页面显示服务端返回规则;奖励配置后来修改不影响该申请快照。领取重试始终使用 `round_no=1`,不会重复入账。
 
-`rewards.status=paid` 表示已可用,`locked` 表示已发入冻结余额待完成流水。倍数0直接可用,大于0时独立累计奖金发放后有效投注,达到“奖金×倍数”自动解冻。活动下架后仍可查询本人rewards,历史记录保留。
+优惠券模式领取成功后:邀请人获得 `inviter_coupons` 全部券;计入人数的被邀请人(按注册先后取前 `invite_count` 名)每人获得一套 `invitee_coupons`,被邀请人在自己的券包里看到。`claim` 返回的 `reward.amount` 为本次发出的券抵扣总额,`reward.status=paid`,钱包余额不变。
 
-后台配置见[《净亏邀请-后台接口》](../../../bot-28/后台/独立活动/净亏邀请-后台接口.md)。
+现金模式 `rewards.status=paid` 表示已可用,`locked` 表示已发入冻结余额待完成流水。倍数0直接可用,大于0时独立累计奖金发放后有效投注,达到“奖金×倍数”自动解冻。活动下架后仍可查询本人rewards,历史记录保留。
 
-正常API请求自动采集的是活跃时长估算:无请求的静态停留不会继续增加时长,后台自动轮询可能计入;不要将其展示为精确在线时长。
+活跃时长为估算值,页面使用“活跃时长”标签,不作为精确在线时长展示。

+ 35 - 10
docs/前端交付/交付清单.json

@@ -26,9 +26,9 @@
       "role": "后台",
       "kind": "目录",
       "git_status": "M",
-      "source_sha256": "917499147f3b1bfd45d0708c74abc057805e455bff2236895e481c45cbbdb2a6",
-      "delivery_sha256": "ba19e5c79a291ad42e16cbde3d7e36df90c84fbca331fd68294188fbd19028a1",
-      "revised_on": "2026-10-26"
+      "source_sha256": "e372b92a6d19ea20578c17b099a1b7c3595bf21f625f8054e83fefbb6770a52d",
+      "delivery_sha256": "9f178fc88b8441bc2686b9a80aaa2dd9b3fdf29dcbc0c3351c7f6e8927308bd0",
+      "revised_on": "2026-11-02"
     },
     {
       "project": "bot-28",
@@ -37,9 +37,9 @@
       "role": "后台",
       "kind": "正文",
       "git_status": "M",
-      "source_sha256": "0a790b82c6b1fa70de28c9ba497b0c53bb34d33cf7f0d0e0a0ad69e3f1f05c7c",
-      "delivery_sha256": "96823f2575284ceeed2d75c29e8cdafef5e0f50ba944b72840b7534f8d112eaf",
-      "revised_on": "2026-10-26"
+      "source_sha256": "93f83870d5fce3372d40dada0b4e02f4a7d17aed8e682ef7b83aa439cc4773e9",
+      "delivery_sha256": "93f83870d5fce3372d40dada0b4e02f4a7d17aed8e682ef7b83aa439cc4773e9",
+      "revised_on": "2026-11-02"
     },
     {
       "project": "bot-28",
@@ -202,8 +202,9 @@
       "role": "前台",
       "kind": "目录",
       "git_status": "??",
-      "source_sha256": "be9e0fc412e1e825f5d26f0802ed98ef2733a3ed6a4e28886e74e21fa031f3e5",
-      "delivery_sha256": "10d508510ba8676afc7b0bbba2ded573be23e6e4e57ac6cedaef8da1096419ca"
+      "source_sha256": "1e48a382d67d4711e4cbc4a5461c211cb07d801e17d05b928c1fc7ee5f66149f",
+      "delivery_sha256": "1e48a382d67d4711e4cbc4a5461c211cb07d801e17d05b928c1fc7ee5f66149f",
+      "revised_on": "2026-11-02"
     },
     {
       "project": "melbet_sport-api",
@@ -212,8 +213,9 @@
       "role": "前台",
       "kind": "正文",
       "git_status": "??",
-      "source_sha256": "65b495054d2f776b32c6a0c5f30218990034e8ca4f65cfe67c4250143eb80dea",
-      "delivery_sha256": "55d9d4852e146c238f1258fa4de13a55c40ccf4f03fde7b5c2a6a6c5a5ae8c89"
+      "source_sha256": "7d3b17c11a0aafa1eedbedac4dd07d249497be21d9e670a7bd5ae69cde8e4249",
+      "delivery_sha256": "7d3b17c11a0aafa1eedbedac4dd07d249497be21d9e670a7bd5ae69cde8e4249",
+      "revised_on": "2026-11-02"
     },
     {
       "project": "melbet_sport-api",
@@ -255,6 +257,17 @@
       "source_sha256": "4847ee80e25838f7b9be376265effe2a446cbe77c1720d326bf677180ebf5197",
       "delivery_sha256": "7176b8dc8b493fbb4ad754fff7d394590719452f861c6a260a8ac4c43ddc9a1d",
       "revised_on": "2026-11-01"
+    },
+    {
+      "project": "melbet_sport-api",
+      "source_path": "docs/独立活动/优惠券券包-会员接口.md",
+      "delivery_path": "melbet_sport-api/前台/独立活动/优惠券券包-会员接口.md",
+      "role": "前台",
+      "kind": "正文",
+      "git_status": "??",
+      "source_sha256": "e750345f581a4854d2cdd2555000d21ae34a5b49c8593a219a56ef6cfb413eff",
+      "delivery_sha256": "e750345f581a4854d2cdd2555000d21ae34a5b49c8593a219a56ef6cfb413eff",
+      "revised_on": "2026-11-02"
     }
   ],
   "excluded": [
@@ -387,6 +400,18 @@
       "project": "bot-28",
       "branch": "master",
       "description": "补充操作弹窗期间暂停当前列表自动更新的交互约定及参考钩子;弹窗关闭后同步列表,统计角标独立更新;同步交付副本与校验值。"
+    },
+    {
+      "date": "2026-11-03",
+      "project": "bot-28 / melbet_sport-api",
+      "branch": "master",
+      "description": "净亏邀请增加奖励模式切换(现金/优惠券)及优惠券配置,后台改为 detail?type=7 单页平铺表单;新增会员券包接口与 F1 下注 coupon_id;有效投注按券后实付计算;同步交付副本与校验值。"
+    },
+    {
+      "date": "2026-11-02",
+      "project": "bot-28 / melbet_sport-api",
+      "branch": "master / dev",
+      "description": "会员券包与管理后台净亏邀请接入文档移出后台派奖、输赢及有效投注计算,券包文档可单独发送;移除不必要的后台引用,更新两端前端目录及交付包内引用路径,核对五份来源与副本校验值。"
     }
   ]
 }

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

@@ -27,6 +27,7 @@
 | melbet_sport-api | `docs/统计装置与活动采集-前台接口.md` | ?? | [统计装置与活动采集-前台接口.md](melbet_sport-api/前台/统计装置与活动采集-前台接口.md) | 正文 |
 | melbet_sport-api | `docs/独立活动/会员接入.md` | ?? | [会员接入.md](melbet_sport-api/前台/独立活动/会员接入.md) | 目录 |
 | melbet_sport-api | `docs/独立活动/净亏邀请-会员接口.md` | ?? | [净亏邀请-会员接口.md](melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md) | 正文 |
+| melbet_sport-api | `docs/独立活动/优惠券券包-会员接口.md` | ??(2026-11-03 增补) | [优惠券券包-会员接口.md](melbet_sport-api/前台/独立活动/优惠券券包-会员接口.md) | 正文 |
 | melbet_sport-api | `docs/独立活动/首充奖励-会员接口.md` | ?? | [首充奖励-会员接口.md](melbet_sport-api/前台/独立活动/首充奖励-会员接口.md) | 正文 |
 | melbet_sport-api | `docs/独立活动/转盘活动-会员接口.md` | ?? | [转盘活动-会员接口.md](melbet_sport-api/前台/独立活动/转盘活动-会员接口.md) | 正文 |
 | melbet_sport-api | `docs/独立活动/转盘播报示例.json` | ?? | [转盘播报示例.json](melbet_sport-api/前台/独立活动/转盘播报示例.json) | 示例数据 |

+ 116 - 0
docs/后端/净亏邀请优惠券-部署说明.md

@@ -0,0 +1,116 @@
+# 净亏邀请优惠券-部署说明
+
+净亏邀请(type=7)新增奖励模式切换:`cash` 现金(原逻辑)/ `coupon` 优惠券。优惠券模式领奖时不发现金,给邀请人和计入人数的被邀请人发券;券只能在 F1 自有游戏(`self:sport`、`self:lottery`)下注时抵扣。
+
+接入文档:[后台](../总后台/独立活动/净亏邀请-后台接口.md) · [会员净亏邀请](../../../melbet_sport-api/docs/独立活动/净亏邀请-会员接口.md) · [会员券包](../../../melbet_sport-api/docs/独立活动/优惠券券包-会员接口.md)。
+
+## 发布顺序
+
+两个项目共用数据库,**先在 bot-28 执行迁移,再发布两个项目代码**:
+
+```bash
+php artisan migrate
+```
+
+迁移 `2026_11_03_120000_add_promotion_coupons.php`:
+
+- 新建 `promotion_coupons`:券,唯一键 `(reward_id, user_id, role, seq)` 防重复发券。
+- 新建 `promotion_coupon_uses`:券在各订单上的分摊明细,用于退款归还。
+- 在 `sport_order`、`sport_game_order`、`lhc_order`、`lhc_order_item` 加 `coupon_id`(可空)和 `coupon_discount`(默认0)。
+
+历史订单 `coupon_discount=0`,所有计算结果与原来一致,不需要回填或重算。活动配置不需要改:旧 `params` 缺少的券字段读取时按默认值(现金模式)补齐。
+
+如果 melbet_sport-api 先于迁移上线,带券的下注、券包和净亏邀请详情会因缺表报错;不带券的下注不写新字段,不受影响。
+
+## 口径
+
+- **派奖**:仍按下注原额计算,赔率、和局/走水退本金都不变。
+- **实付**:注单原额 − `coupon_discount`,钱包扣款、投注流水、`rebates.betting_amount`、冻结流水解冻都按实付。
+- **净输赢**:派奖 − 实付。写入 `lhc_order_item.profit_and_loss`、`sport_order.profit_and_loss` 以及分享事实。
+- **有效投注**:`min(实付, |净输赢|)`。
+  - 由 `BetFactSynchronizer` 写入 `share_bet_facts`,分享返佣、有效会员、净亏、首充/转盘流水都读这里。
+  - PC28 结算(`BetService::betSettled`)的笔笔返有效投注、老活动打码量也按实付。
+- **退款**:退实付金额。该券分摊到的订单全部退款后券恢复 `unused`,已过期的券恢复后仍不可用。
+- 报表 `reporting_orders` 通过同一投影计算,用券订单的投注额也是实付。
+- 代理流水佣金仍按注单原额 `SUM(amount)`,本次未改。
+
+## 改动范围
+
+**bot-28**
+
+- 规则与后台:
+  - `PromotionRules`:券配置校验
+  - `PromotionAdminService` 与 `admin/Promotion`:`detail?type=7`、`options` 新增选项、`records kind=coupons`
+- 发券:`PromotionService` 镜像副本
+- 有效投注与结算:
+  - `BetFactSynchronizer`:实付口径
+  - `BetService`:PC28 结算与开奖失败退分
+- 退款:
+  - `PromotionCouponService`:退款归还券
+  - 后台订单退款(体彩、足球小游戏、六合彩)
+
+**melbet_sport-api**
+
+- 规则与发券:
+  - `PromotionRules`
+  - `PromotionService`:领奖发券、详情 `coupons`
+- 券的使用:
+  - `common/service/coupon/CouponService`
+  - `CouponController`:`/api/coupon/list`、`/api/coupon/count`
+- 5 个下注入口带 `coupon_id`
+- 退款与结算:
+  - 会员撤单与体育自动退款
+  - 六合彩/极速结算的 `profit_and_loss`、足球结算的 `profit_and_loss`
+
+## 后台人工退款:既有问题已修正
+
+- 体彩、足球小游戏、六合彩和旧极速订单的后台退款均增加可用余额,不再扣减余额。
+- 体彩和足球小游戏退回订单金额减券抵扣;六合彩及使用 `lhc_order` 的极速彩票组单按 `total_amount` 减券抵扣退款。历史 `total_amount` 缺失时按单注金额与号码数量还原总额。旧 `jisu_game_order` 的一笔记录即一笔下注,按该笔 `amount` 退款。
+- 四个入口按订单 ID 顺序锁定订单,并锁定钱包。重复退款跳过已完成记录;钱包缺失或任一笔流水写入失败时,整批退款回滚,订单、余额和券状态保持原状。
+- 退款流水保留 `related_id`,对应到原订单,金额与钱包增加额一致。
+
+修复用于后续退款,不自动调整历史已发生的错账。历史退款需要按原订单、钱包和资金流水单独核对,不能通过重复点击退款补账。
+
+## 退款回归修正
+
+- 足球自动退款会重新锁定并读取订单,以数据库中的金额、折扣和结算状态为准。只有仍有效、已付款、未结算且未退款的订单会返款;已退款记录重复调用不会再次返款或写流水。
+- 自动退款的返款、资金流水、订单状态和优惠券归还在同一事务中。写流水或更新失败时整体回滚,也可加入外层赛事退款事务。
+- 优惠券按比例分摊到某笔订单的金额即使为 `0.00`,该订单仍按 `coupon_id` 进入用券退款流程,按完整订单总额减分摊折扣退款,并标记对应使用记录已退款。
+- 两个项目的退券方法都先锁优惠券,再锁并更新使用记录,使用当前读判断是否还有未退款分摊。跨项目并发退款和旧的可重复读快照不会漏掉最后一次归券。
+- 券重新使用后,旧订单的重复退券调用不会清除新的使用状态。退券方法可独立开启事务;涉及钱包或订单状态的调用方仍须把整个退款流程放进外层事务。
+
+## 验证
+
+- bot-28:
+
+  ```bash
+  php -d xdebug.mode=off -d error_reporting='E_ALL & ~E_DEPRECATED' vendor/bin/phpunit tests/Integration
+  ```
+
+  新增 `test_coupon_mode_issues_inviter_and_counted_invitee_coupons_once_without_cash`、`test_coupon_orders_use_paid_stake_for_net_profit_and_effective_bet`。
+  开奖失败退款及零折扣分摊回归:
+
+  ```bash
+  php tests/Regression/coupon_refund_regression.php
+  vendor/bin/phpunit --no-configuration --bootstrap tests/bootstrap-agent.php tests/Integration/AdminOrderRefundTest.php
+  ```
+- melbet_sport-api:
+
+  ```bash
+  php tests/coupon_integration.php
+  php tests/coupon_refund_integration.php
+  php tests/promotion_integration.php
+  ```
+
+  `tests/fixtures/promotion_schema.json` 已加入两张券表的结构。
+
+上述回归只使用隔离 SQLite,模拟资金流水写入失败,验证整单回滚、重复退款、券包过期判断、已结算订单保护及外层事务回滚,不连接业务数据库。
+
+两个项目之间的并发退券使用独立的 MySQL 回归。在禁用网络的临时测试 MySQL 上准备专用套接字后,从 bot-28 执行:
+
+```bash
+COUPON_TEST_MYSQL_SOCKET=/tmp/b28coupon-fix-test.sock \
+  php tests/Regression/coupon_refund_mysql.php
+```
+
+脚本只接受 `/tmp/b28coupon-fix-*.sock`,会在该测试实例的 `coupon_refund_regression` 数据库中重建两张券表。它覆盖后台/后台、会员/会员及两个方向的跨项目退款,并让第二个事务先建立旧的可重复读快照;最终必须全部记为退款且券恢复可用。禁止使用业务数据库套接字。会员项目默认从 bot-28 的同级 `melbet_sport-api` 目录加载,可用 `COUPON_MEMBER_ROOT` 指定测试副本路径。

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

@@ -4,10 +4,10 @@
 
 | 活动 | 独立后台文档 | 对应会员文档 |
 |---|---|---|
-| 净亏邀请 | [净亏邀请-后台接口](独立活动/净亏邀请-后台接口.md) | [净亏邀请-会员接口](../../../melbet_sport-api/docs/独立活动/净亏邀请-会员接口.md) |
+| 净亏邀请 | [净亏邀请-后台接口](独立活动/净亏邀请-后台接口.md) | [净亏邀请-会员接口](../../../melbet_sport-api/docs/独立活动/净亏邀请-会员接口.md) · [优惠券券包-会员接口](../../../melbet_sport-api/docs/独立活动/优惠券券包-会员接口.md) |
 | 首充奖励 | [首充奖励-后台接口](独立活动/首充奖励-后台接口.md) | [首充奖励-会员接口](../../../melbet_sport-api/docs/独立活动/首充奖励-会员接口.md) |
 | 转盘活动 | [转盘活动-后台接口](独立活动/转盘活动-后台接口.md) | [转盘活动-会员接口](../../../melbet_sport-api/docs/独立活动/转盘活动-会员接口.md) |
 
 本页仅保留导航;每个活动的参数、示例、上下架、资格和发奖说明均在各自文档内。
 
-新增按钮与新增类型下拉使用 `GET /admin/promotion/options` 返回的 `create_types`,当前只有首充奖励(`value=8`)。`types` 仍提供三种类型用于筛选。固定活动从列表读取现有 `id` 后编辑保存。
+新增按钮与新增类型下拉使用 `GET /admin/promotion/options` 返回的 `create_types`,当前只有首充奖励(`value=8`)。`types` 仍提供三种类型用于筛选。固定活动从列表读取现有 `id` 后编辑保存;净亏邀请改为单页平铺表单,直接用 `GET /admin/promotion/detail?type=7` 读取,不再经过列表。

+ 144 - 41
docs/总后台/独立活动/净亏邀请-后台接口.md

@@ -1,82 +1,185 @@
 # 净亏邀请-后台接口
 
-业务类型固定为 `7`。这是固定活动,仅编辑、上架或下架,不提供新增、复制或另存为活动入口。
+本文面向管理后台前端,说明净亏邀请配置表单和记录查询的接入方式。
+
+业务类型固定为 `7`。这是固定活动,只有一条配置,仅编辑、上架或下架,不提供新增、复制或另存为活动入口。
 
 本活动使用独立的 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
 
 金额以字符串返回,直接使用接口值展示:整数不带小数,小数保留两位;超过两位的小数截去,不四舍五入。例如 `200.00000000 → 200`、`200.12000000 → 200.12`、`200.1 → 200.10`、`200.129 → 200.12`。页面不再统一补齐多位小数。
 
+## 页面形式:单页平铺表单
+
+净亏邀请不再使用"列表 → 编辑"。菜单进入后直接展示一张平铺表单:
+
+1. 进入页面时并行请求 `GET /admin/promotion/options` 和 `GET /admin/promotion/detail?type=7`。
+2. 用详情回填全部输入框,保存的 `id` 取详情返回值,不写死。
+3. 页面底部"保存"调用 `POST /admin/promotion/submit`;上下架可以是表单内的开关,也可以是单独按钮调用 `POST /admin/promotion/status`。
+4. 参与和发奖记录、已发优惠券放在同页的 Tab 或折叠区,按需请求 `GET /admin/promotion/records`。
+
+不再请求 `GET /admin/promotion?type=7` 列表。
+
 ## 配置接口
 
 | 方法 | 路径 | 参数与用途 |
 |---|---|---|
-| GET | `/admin/promotion?type=7` | 仅查看本活动类型;可选 `page/limit/status/title` |
-| GET | `/admin/promotion/options` | 读取 `defaults["7"]` 默认规则 |
-| GET | `/admin/promotion/detail?id=1` | 配置详情,返回 `params` 对象及 `first_published_at` |
-| POST | `/admin/promotion/submit` | 编辑现有活动,必须带 `id`,固定 `type=7` |
+| GET | `/admin/promotion/detail?type=7` | 读取唯一的净亏邀请配置,返回 `id`、`params` 对象及 `first_published_at`;也可用 `id` 读取 |
+| GET | `/admin/promotion/options` | 默认规则 `defaults["7"]`、奖励模式 `reward_modes`、券可用游戏 `coupon_categories` |
+| POST | `/admin/promotion/submit` | 保存,必须带详情返回的 `id`,固定 `type=7` |
 | POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
-| GET | `/admin/promotion/records` | 查看参与或发奖记录 |
+| GET | `/admin/promotion/records` | 查看参与、发奖及已发优惠券 |
+
+`detail` 的 `id` 和 `type` 二选一,`type` 只接受 `7`(净亏邀请)或 `9`(转盘)。不带 `id` 保存会返回 `code=-3`,提示"净亏和转盘活动不支持新增,请编辑现有活动"。
+
+`options` 新增:
+
+```json
+{
+  "reward_modes": [{"value": "cash", "label": "现金"}, {"value": "coupon", "label": "优惠券"}],
+  "coupon_categories": [{"value": "self:sport", "label": "F1体育"}, {"value": "self:lottery", "label": "F1彩票"}]
+}
+```
+
+## 表单字段
+
+### 通用字段(与原来相同)
+
+保存必填根字段:`id/title/type/status/start_time/end_time` 及三语 `translations`。时间按UTC+8填写,结束必须晚于开始。可选 `lang/sort/content/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。
 
-列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。先从列表读取现有活动,再使用其 `id` 获取详情、保存或切换状态;不写死示例ID。不带 `id` 保存会返回 `code=-3`,提示“净亏和转盘活动不支持新增,请编辑现有活动”。
+可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,只展示不提交。
 
-保存必填根字段:`id/title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `lang/sort/content/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;编辑局部规则时保留其余原值;表单以详情返回的当前配置回填。
+净亏邀请没有图片字段,不展示图片上传/预览,也不校验或提交 `image`。
 
-可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。编辑时省略字段保留原值。
+### 规则 `params`
 
-可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,不能手动改写。
+`params` 传对象,未知键报错;只传部分键时其余保留原值。表单以详情返回的当前值回填,保存时建议整组提交。
 
-净亏邀请编辑页没有图片字段,不展示图片上传/预览,也不校验或提交 `image`。
+| 字段 | 默认值 | 显示条件 | 含义 |
+|---|---|---|---|
+| loss_amount | 500.00 | 始终 | 申请所需累计净亏 |
+| invite_count | 3 | 始终 | 申请后需新增的有效直属会员数,1–1000 |
+| reward_amount | 500.00 | 始终 | 现金模式为发放金额;优惠券模式为邀请人券抵扣总额上限 |
+| reward_mode | `cash` | 始终 | 奖励模式切换:`cash` 现金 / `coupon` 优惠券 |
+| turnover_ratio | 0.00 | 仅现金模式 | 奖金所需有效投注倍数;优惠券模式不使用 |
+| coupon_valid_from | null | 仅优惠券模式 | 所有券统一的可用开始时间,`Y-m-d H:i:s`(UTC+8) |
+| coupon_valid_until | null | 仅优惠券模式 | 所有券统一的可用结束时间,须晚于开始 |
+| coupon_categories | [] | 仅优惠券模式 | 可用游戏,多选,取值 `self:sport` / `self:lottery`(只限 F1 自有游戏,第三方不可用) |
+| inviter_coupons | [] | 仅优惠券模式 | 邀请人券档位数组,见下 |
+| invitee_coupons | [] | 仅优惠券模式 | 每名被邀请人券档位数组,见下;可为空 |
 
-## 编辑保存示例
+券档位每一行三个输入框,可增删行,每组最多10行:
 
-`id` 使用读取接口返回值,下面的1仅为示例。
+| 字段 | 示例 | 校验 |
+|---|---|---|
+| min_amount | 100 | 使用门槛:单次下注满该金额可用,最多两位小数 |
+| discount | 50 | 抵扣金额,大于0且不高于 `min_amount` |
+| quantity | 2 | 份数,1–100 的整数 |
+
+例如邀请人券配置"满100抵50 × 2、满200抵100 × 1":
+
+```json
+[
+  {"min_amount": "100", "discount": "50", "quantity": 2},
+  {"min_amount": "200", "discount": "100", "quantity": 1}
+]
+```
+
+### 模式切换规则
+
+- 切到 `cash` 时隐藏券相关输入框,但保存时**原样提交**已填写的券配置;服务端保留这些值,切回 `coupon` 时无需重填。现金模式下券配置只校验格式(档位数值合法),不校验必填。
+- 切到 `coupon` 时隐藏 `turnover_ratio`,以下为必填:
+  - `inviter_coupons` 至少一行
+  - `coupon_valid_from`、`coupon_valid_until`
+  - `coupon_categories` 至少选一个
+- 优惠券模式下邀请人券的 `Σ(discount × quantity)` 不得超过 `reward_amount`。页面可实时显示"已配置抵扣总额 / 奖励金额",服务端以同样规则校验。被邀请人券不计入这个上限。
+
+常见错误提示(`code=-3`,直接展示 `msg`):
+
+| msg | 原因 |
+|---|---|
+| 优惠券抵扣总额不能超过奖励金额 | 邀请人券抵扣×份数之和超过 `reward_amount` |
+| 请至少配置一档邀请人优惠券 | 优惠券模式下邀请人券为空 |
+| 请填写优惠券可用时间 | 缺开始或结束时间 |
+| 优惠券结束时间必须晚于开始时间 | 时间顺序错误 |
+| 请选择优惠券可用游戏类型 | 未勾选游戏 |
+| inviter_coupons参数错误:抵扣金额须大于0且不高于使用门槛,份数为1至100 | 某行数值不合法 |
+| reward_mode参数错误 / coupon_categories参数错误 | 取值不在选项内 |
+
+### 保存示例(优惠券模式)
+
+`id` 使用详情返回值,下面的 1 仅为示例。
 
 ```json
 {
   "id": 1,
   "title": "净亏邀请奖励",
   "type": 7,
-  "status": 0,
-  "start_time": "2026-10-26 00:00:00",
-  "end_time": "2027-10-26 23:59:59",
-  "publish_platforms": [
-    "PC",
-    "H5",
-    "APP"
-  ],
+  "status": 1,
+  "start_time": "2026-11-03 00:00:00",
+  "end_time": "2027-11-03 23:59:59",
+  "publish_platforms": ["PC", "H5", "APP"],
+  "translations": {
+    "zh": {"title": "净亏邀请奖励", "content": "<p>…</p>"},
+    "en": {"title": "Net loss invite", "content": "<p>…</p>"},
+    "vi": {"title": "Mời bạn hoàn lỗ", "content": "<p>…</p>"}
+  },
   "params": {
     "loss_amount": "500",
     "invite_count": 3,
     "reward_amount": "500",
-    "turnover_ratio": "0.00"
+    "reward_mode": "coupon",
+    "turnover_ratio": "0",
+    "coupon_valid_from": "2026-11-03 00:00:00",
+    "coupon_valid_until": "2026-12-31 23:59:59",
+    "coupon_categories": ["self:sport", "self:lottery"],
+    "inviter_coupons": [
+      {"min_amount": "100", "discount": "50", "quantity": 4},
+      {"min_amount": "200", "discount": "100", "quantity": 3}
+    ],
+    "invitee_coupons": [
+      {"min_amount": "100", "discount": "50", "quantity": 1},
+      {"min_amount": "200", "discount": "100", "quantity": 1}
+    ]
   }
 }
 ```
 
-## 业务规则与字段
+邀请人券抵扣总额 `50×4 + 100×3 = 500`,等于奖励金额,可以保存。
 
-| params字段 | 默认值 | 含义 |
-|---|---|---|
-| loss_amount | 500.00 | 申请所需累计净亏 |
-| invite_count | 3 | 申请后需新增的有效直属会员数 |
-| reward_amount | 500.00 | 达标后领取奖金 |
-| turnover_ratio | 0.00 | 奖金所需有效投注倍数 |
+## 业务规则
 
-1. 从本活动第一次上架后下单的真实注单累计净亏:`max(0,-已结算且未撤销注单净输赢合计)`,盈亏相抵。充值、提现、赠送和转账不算游戏损益。
-2. 达到净亏门槛后会员点击申请;申请成功固定当时的奖励规则、净亏快照及申请时间。无效申请不消耗机会,重复有效申请返回原进度。
-3. 只计申请后新注册且建立直属关系的下级。必须同时达到系统累计真实充值、累计有效投注双门槛;使用当日已生效政策,系统修改门槛按既有规则次日生效,不写死截图200。
-4. 有效投注逐笔按 `min(本金, |净输赢|)` 计算,只计已结算未撤销的真实注单。人工上分、活动赠送不计真实充值。
-5. 新增有效直属会员达到人数后,会员自行领取。每名会员在本活动只能领取一次,下架再上架不重置机会或首次上架时间。
-6. 奖金按申请时保存的规则发放。`turnover_ratio=0` 直接进入可用余额,大于0进入冻结余额,发放后新增有效投注达到“奖金×倍数”后自动解冻,各笔奖金独立计算。
+1. 净亏和有效邀请人数直接使用接口返回值。
+2. 达到净亏门槛后会员点击申请;申请成功时固定当时的整套规则(包括奖励模式、券档位、券时间与游戏),之后修改配置不影响已申请的会员。无效申请不消耗机会,重复有效申请返回原进度。
+3. 会员是否达到活动条件由服务端判断,记录页显示对应状态。
+4. 新增有效直属会员达到人数后,会员自行领取。每名会员在本活动只能领取一次,下架再上架不重置机会或首次上架时间。
+5. **现金模式**:记录页显示接口返回的奖励金额和状态。
+6. **优惠券模式**:领取时不发现金,改为发券:
+   - 邀请人按 `inviter_coupons` 每份一张券。
+   - 计入人数的被邀请人每人按 `invitee_coupons` 一套券。如果有效下级多于 `invite_count`,按注册先后取前 `invite_count` 名。
+   - 所有券使用同一可用时间和可用游戏。
+   - 奖励记录 `amount` 为本次发出的券抵扣总额,状态为 `paid`;记录页直接展示,会员钱包不增加现金。
+7. 券只能在 F1 自有游戏(F1体育、F1彩票)下注时使用;退款后的可用状态以券包接口返回为准。
 
-## 参与与发奖记录
+## 参与、发奖与优惠券记录
 
-`GET /admin/promotion/records?activity_id=1&kind=accounts&page=1&limit=20` 查看申请记录,`kind=rewards` 查看奖金,可追加会员数据库ID `user_id`。返回 `total/data`。
+`GET /admin/promotion/records?activity_id=1&kind=accounts&page=1&limit=20` 查看申请记录,`kind=rewards` 查看奖金,可追加会员 ID `user_id`(使用会员列表返回的 `id`)。返回 `total/data`。
 
-申请记录包含 `applied_at/application_snapshot`;奖金有 `reserved/locked/paid` 状态和资金流水关联。没有有效申请前不允许领奖,重复领取不会重复入账。停止活动使用下架,不删除参与历史。
+申请记录包含 `applied_at/application_snapshot`;奖金有 `reserved/locked/paid` 状态和资金流水关联(优惠券模式无流水)。没有有效申请前不允许领奖,重复领取不会重复入账或发券。停止活动使用下架,不删除参与历史。
 
-## 权限
+`kind=coupons` 查看已发优惠券,可加 `user_id`(匹配持券人或邀请人)和 `status=unused/used`:
+
+| 字段 | 含义 |
+|---|---|
+| id / activity_id / reward_id | 券ID、活动、来源奖励记录 |
+| user_id / member_id | 持券会员 |
+| role | `inviter` 邀请人券 / `invitee` 被邀请人券 |
+| inviter_user_id | 发起邀请的会员 |
+| min_amount / discount | 使用门槛、抵扣金额 |
+| categories | 可用游戏数组 |
+| valid_from / valid_until | 可用时间 |
+| status | `unused` 未使用 / `used` 已使用 |
+| used_source / used_stake / used_at | 使用的注单类型、下注毛额、使用时间 |
 
-独立活动菜单和按钮由后台前端配置;读取、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。
+## 权限
 
-对应会员端文档:[《净亏邀请-会员接口》](../../../../melbet_sport-api/docs/独立活动/净亏邀请-会员接口.md)。
+独立活动菜单和按钮由后台前端配置;读取、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`,本次没有新增权限。

+ 36 - 1
tests/Integration/PromotionActivitiesTest.php

@@ -53,7 +53,7 @@ class PromotionActivitiesTest extends TestCase
             'reporting_activity' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,session_hash TEXT,observed_at TEXT',
             'menus' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,parent_id INTEGER,title TEXT,icon TEXT,uri TEXT,permission_name TEXT,sort INTEGER,status INTEGER,type INTEGER,created_at TEXT,updated_at TEXT',
         ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
-        foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php', '2026_10_27_130000_initialize_fixed_promotion_activities.php', '2026_10_27_140000_add_promotion_activity_translations.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
+        foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php', '2026_10_27_130000_initialize_fixed_promotion_activities.php', '2026_10_27_140000_add_promotion_activity_translations.php', '2026_11_03_120000_add_promotion_coupons.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
         $this->member(1); $this->member(2, 1);
         DB::table('share_policies')->insert(['id' => 1, 'effective_date' => '2026-10-01', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '300']);
         $this->service = new PromotionService();
@@ -347,6 +347,41 @@ class PromotionActivitiesTest extends TestCase
         $this->assertSame(1, DB::table('promotion_rewards')->count()); $this->assertSame('500.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
     }
 
+    public function test_coupon_mode_issues_inviter_and_counted_invitee_coupons_once_without_cash(): void
+    {
+        $coupons = ['reward_mode' => 'coupon', 'reward_amount' => '300', 'invite_count' => 2,
+            'coupon_valid_from' => '2026-10-26 00:00:00', 'coupon_valid_until' => '2026-11-30 23:59:59', 'coupon_categories' => ['self:lottery', 'self:sport'],
+            'inviter_coupons' => [['min_amount' => '100', 'discount' => '50', 'quantity' => 2], ['min_amount' => '200', 'discount' => '100', 'quantity' => 1]],
+            'invitee_coupons' => [['min_amount' => '20', 'discount' => '10', 'quantity' => 1]]];
+        $this->invalid(fn () => $this->activity(7, array_replace($coupons, ['reward_amount' => '150'])), '优惠券抵扣总额不能超过奖励金额');
+        $this->invalid(fn () => $this->activity(7, array_replace($coupons, ['coupon_categories' => []])), '可用游戏类型');
+        $this->invalid(fn () => $this->activity(7, array_replace($coupons, ['inviter_coupons' => [['min_amount' => '40', 'discount' => '50', 'quantity' => 1]]])), '使用门槛');
+        $id = $this->activity(7, $coupons);
+        $this->assertSame(['self:sport', 'self:lottery'], (new PromotionAdminService())->detail(null, 7)['params']['coupon_categories']);
+        $this->bet(1, '600', '-600', '2026-10-26 12:00:00'); $this->service->apply($id, 1);
+        $this->travel('2026-10-26 13:00:00');
+        foreach ([3, 4, 5] as $child) {
+            $this->member($child, 1, '2026-10-26 13:00:00'); $this->rawDeposit($child, '200', '2026-10-26 13:00:00'); $this->bet($child, '300', '-300', '2026-10-26 13:00:00');
+        }
+        $this->service->claim($id, 1, 1); $this->service->claim($id, 1, 1);
+        $reward = DB::table('promotion_rewards')->first();
+        $this->assertSame('paid', $reward->status); $this->assertEquals(220, $reward->amount); $this->assertEquals(0, $reward->required_flow);
+        $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
+        $this->assertSame(0, DB::table('balance_logs')->where('change_type', '优惠活动')->count());
+        $this->assertSame(3, DB::table('promotion_coupons')->where('user_id', 1)->where('role', 'inviter')->count());
+        // Only the first invite_count qualified invitees are counted and receive coupons.
+        $this->assertSame([3, 4], DB::table('promotion_coupons')->where('role', 'invitee')->orderBy('user_id')->pluck('user_id')->map(fn ($v) => (int)$v)->all());
+        $state = $this->service->detail($id, 1, 'H5');
+        $this->assertSame('claimed', $state['status']); $this->assertSame('coupon', $state['rules']['reward_mode']);
+        $this->assertSame([2, 1], array_column($state['coupons'], 'quantity'));
+        $this->assertEquals(220, DB::table('promotion_activities')->where('id', $id)->value('budget_used'));
+        // Switching back to cash keeps the coupon settings for the next switch.
+        $this->activity(7, ['reward_mode' => 'cash']);
+        $this->assertCount(2, json_decode(DB::table('promotion_activities')->where('id', $id)->value('params'), true)['inviter_coupons']);
+        $records = (new PromotionAdminService())->records(['activity_id' => $id, 'kind' => 'coupons', 'user_id' => 1]);
+        $this->assertSame(5, $records['total']);
+    }
+
     public function test_successful_online_time_deduplicates_devices_and_does_not_count_closed_session_as_online(): void
     {
         $id = $this->activity();

+ 28 - 0
tests/Integration/SharingBetFactsTest.php

@@ -37,13 +37,16 @@ class SharingBetFactsTest extends TestCase
         Schema::create('lhc_order', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('type'); $t->integer('lottery_status');
             $t->decimal('amount', 24, 10); $t->decimal('total_amount', 24, 10)->nullable(); $t->bigInteger('created_at'); $t->bigInteger('updated_at');
+            $t->decimal('coupon_discount', 14, 2)->default(0);
         });
         Schema::create('lhc_order_item', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('lottery_status');
             $t->decimal('win_amount', 24, 10)->nullable(); $t->decimal('profit_and_loss', 24, 10)->nullable(); $t->bigInteger('updated_at');
+            $t->decimal('coupon_discount', 14, 2)->default(0);
         });
         Schema::create('sport_game_order', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->integer('status'); $t->decimal('amount', 24, 10); $t->decimal('win_amount', 24, 10)->default(0); $t->timestamps();
+            $t->decimal('coupon_discount', 14, 2)->default(0);
         });
         Schema::create('sport_order', function (Blueprint $t) {
             $t->id(); $t->string('user_id'); $t->integer('status'); $t->integer('pay_status'); $t->integer('return_status')->default(0);
@@ -105,6 +108,31 @@ class SharingBetFactsTest extends TestCase
         $this->assertSame(2, DB::table('share_bet_facts')->count());
     }
 
+    public function test_coupon_orders_use_paid_stake_for_net_profit_and_effective_bet(): void
+    {
+        // 2 numbers x 100 with a 60 coupon split 30/30: paid 70 each, payouts still on the full 100.
+        $this->lottery();
+        DB::table('lhc_order')->where('id', 10)->update(['coupon_discount' => 60]);
+        DB::table('lhc_order_item')->update(['coupon_discount' => 30]);
+        DB::table('sport_game_order')->insert([
+            ['id' => 1, 'member_id' => '10001', 'status' => 2, 'amount' => 100, 'win_amount' => 200, 'coupon_discount' => 50, 'created_at' => '2026-10-24 10:00:00', 'updated_at' => '2026-10-24 11:00:00'],
+            ['id' => 2, 'member_id' => '10001', 'status' => 1, 'amount' => 100, 'win_amount' => 0, 'coupon_discount' => 50, 'created_at' => '2026-10-24 10:00:00', 'updated_at' => '2026-10-24 11:00:00'],
+        ]);
+        $result = (new BetFactSynchronizer())->sync(null, null, true);
+        $this->assertSame([], $result['errors']);
+        $fact = fn (string $source, string $id) => DB::table('share_bet_facts')->where('source', $source)->where('source_id', $id)->first();
+        $won = $fact('lhc_order_item', '101'); $lost = $fact('lhc_order_item', '102');
+        $this->assertEquals(70, $won->bet_amount); $this->assertEquals(80, $won->net_profit); $this->assertEquals(70, $won->effective_amount);
+        $this->assertEquals(-70, $lost->net_profit); $this->assertEquals(70, $lost->effective_amount);
+        $this->assertEquals(150, $fact('sport_game_order', '1')->net_profit); $this->assertEquals(50, $fact('sport_game_order', '1')->effective_amount);
+        $this->assertEquals(-50, $fact('sport_game_order', '2')->net_profit); $this->assertEquals(50, $fact('sport_game_order', '2')->effective_amount);
+        // A refund of the paid total (not the gross 200) is a consistent full refund.
+        DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 1, 'updated_at' => Carbon::now()->timestamp]);
+        DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 140]);
+        $this->assertSame([], (new BetFactSynchronizer())->sync(null, null, true)['errors']);
+        $this->assertSame(2, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->where('voided', 1)->count());
+    }
+
     public function test_refunded_orders_mark_previous_facts_void_and_preserve_unfinished_facts(): void
     {
         $this->lottery();