fix: 部分收付累计释额并扩展结算 E2E 与 CI
账期收付改为按累计已付同步 credit_ledger,修复多笔 partial_paid 与坏账核销重复释额;新增 API E2E(占成、权限、部分收付/坏账)与 GitHub Actions e2e-api job。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
57
.github/workflows/e2e.yml
vendored
Normal file
57
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: lotterLaravel E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e-api:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.3"
|
||||
extensions: pdo_pgsql, redis
|
||||
coverage: none
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: e2e/package-lock.json
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
working-directory: e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: e2e
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Run E2E stack (API only)
|
||||
env:
|
||||
E2E_UI: "0"
|
||||
CI: "true"
|
||||
run: ./e2e/scripts/run.sh
|
||||
|
||||
- name: Upload artifacts on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-artifacts
|
||||
path: |
|
||||
e2e/artifacts/
|
||||
e2e/logs/
|
||||
if-no-files-found: ignore
|
||||
@@ -3,13 +3,16 @@
|
||||
namespace App\Http\Controllers\Api\V1\E2E;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Services\Agent\AgentNodeService;
|
||||
use App\Services\Integration\PartnerSiteConfigResolver;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\SiteOperatorRoles;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -139,6 +142,87 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function setupSiteOperator(Request $request): JsonResponse
|
||||
{
|
||||
PlatformSystemRoles::ensureAll();
|
||||
|
||||
$roleSlug = (string) ($request->input('role_slug') ?: SiteOperatorRoles::SLUG_SITE_FINANCE);
|
||||
$username = (string) ($request->input('username') ?: 'e2e_site_finance');
|
||||
$password = (string) ($request->input('password') ?: env('E2E_PLAYER_PASSWORD', '12345678'));
|
||||
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
|
||||
|
||||
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||
if ($site === null) {
|
||||
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
|
||||
}
|
||||
|
||||
$roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id');
|
||||
if ($roleId <= 0) {
|
||||
return ApiResponse::error('role not found', 'e2e_role_missing', null, 500);
|
||||
}
|
||||
|
||||
/** @var AdminUser $user */
|
||||
$user = AdminUser::query()->updateOrCreate(
|
||||
['username' => $username],
|
||||
[
|
||||
'name' => 'E2E Site Operator',
|
||||
'email' => null,
|
||||
'password' => $password,
|
||||
'status' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
DB::table('admin_user_site_roles')->updateOrInsert(
|
||||
['admin_user_id' => $user->id, 'site_id' => $site->id],
|
||||
['role_id' => $roleId, 'granted_at' => now()],
|
||||
);
|
||||
|
||||
return ApiResponse::success([
|
||||
'admin_user_id' => (int) $user->id,
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'role_slug' => $roleSlug,
|
||||
'admin_site_id' => (int) $site->id,
|
||||
'site_code' => $siteCode,
|
||||
]);
|
||||
}
|
||||
|
||||
public function resetSiteSettlement(Request $request): JsonResponse
|
||||
{
|
||||
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
|
||||
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||
if ($site === null) {
|
||||
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
|
||||
}
|
||||
|
||||
$siteId = (int) $site->id;
|
||||
$periodIds = DB::table('settlement_periods')
|
||||
->where('admin_site_id', $siteId)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if ($periodIds !== []) {
|
||||
$billIds = DB::table('settlement_bills')
|
||||
->whereIn('settlement_period_id', $periodIds)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if ($billIds !== []) {
|
||||
DB::table('payment_records')->whereIn('settlement_bill_id', $billIds)->delete();
|
||||
}
|
||||
|
||||
DB::table('settlement_adjustments')->whereIn('settlement_period_id', $periodIds)->delete();
|
||||
DB::table('settlement_bills')->whereIn('settlement_period_id', $periodIds)->delete();
|
||||
DB::table('settlement_periods')->where('admin_site_id', $siteId)->delete();
|
||||
}
|
||||
|
||||
return ApiResponse::success([
|
||||
'site_code' => $siteCode,
|
||||
'admin_site_id' => $siteId,
|
||||
'cleared_periods' => count($periodIds),
|
||||
]);
|
||||
}
|
||||
|
||||
public function mintSsoJwt(Request $request): JsonResponse
|
||||
{
|
||||
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
|
||||
|
||||
@@ -105,7 +105,8 @@ final class AgentSettlementBadDebtService
|
||||
if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) {
|
||||
$player = Player::query()->find((int) $original->owner_id);
|
||||
if ($player !== null) {
|
||||
$this->playerCreditService->releaseFromSettlement($player, $unpaid, $originalBillId);
|
||||
$cumulativePaid = (int) $original->paid_amount + $unpaid;
|
||||
$this->playerCreditService->releaseFromSettlement($player, $cumulativePaid, $originalBillId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,9 +86,9 @@ final class SettlementPaymentService
|
||||
$player = Player::query()->find((int) $bill->owner_id);
|
||||
if ($player !== null) {
|
||||
if ((int) $bill->net_amount > 0) {
|
||||
$this->playerCreditService->releaseFromSettlement($player, $payAmount, $billId);
|
||||
$this->playerCreditService->releaseFromSettlement($player, $newPaid, $billId);
|
||||
} elseif ((int) $bill->net_amount < 0) {
|
||||
$this->playerCreditService->applySettlementPayout($player, $payAmount, $billId);
|
||||
$this->playerCreditService->applySettlementPayout($player, $newPaid, $billId);
|
||||
}
|
||||
|
||||
if ($status === 'settled') {
|
||||
|
||||
@@ -359,62 +359,89 @@ final class PlayerCreditService
|
||||
]);
|
||||
}
|
||||
|
||||
public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void
|
||||
/**
|
||||
* @param int $cumulativePaidMinor 账单累计已登记收付(minor),支持部分收付多笔递增。
|
||||
*/
|
||||
public function releaseFromSettlement(Player $player, int $cumulativePaidMinor, int $billId): void
|
||||
{
|
||||
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
|
||||
if ($cumulativePaidMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 幂等闸门:credit_ledger (settlement_bill, settlement_confirm) 唯一;重复调用直接返回。
|
||||
try {
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => $amountMinor,
|
||||
'reason' => 'settlement_confirm',
|
||||
'ref_type' => 'settlement_bill',
|
||||
'ref_id' => $billId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
if ($this->isUniqueViolation($e)) {
|
||||
return;
|
||||
}
|
||||
throw $e;
|
||||
$delta = $this->syncSettlementBillLedger(
|
||||
$player,
|
||||
$billId,
|
||||
'settlement_confirm',
|
||||
$cumulativePaidMinor,
|
||||
);
|
||||
if ($delta > 0) {
|
||||
$this->decreaseUsedCredit($player, $delta);
|
||||
}
|
||||
|
||||
$this->decreaseUsedCredit($player, $amountMinor);
|
||||
}
|
||||
|
||||
public function applySettlementPayout(Player $player, int $amountMinor, int $billId): void
|
||||
/**
|
||||
* @param int $cumulativePaidMinor 账单累计已登记收付(minor),支持部分收付多笔递增。
|
||||
*/
|
||||
public function applySettlementPayout(Player $player, int $cumulativePaidMinor, int $billId): void
|
||||
{
|
||||
if ($amountMinor <= 0) {
|
||||
if ($cumulativePaidMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
$this->syncSettlementBillLedger(
|
||||
$player,
|
||||
$billId,
|
||||
'settlement_payout',
|
||||
$cumulativePaidMinor,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步账期收付台账:每账单每种 reason 仅一行,amount 为累计已付;返回本次应追加的 minor 增量。
|
||||
*/
|
||||
private function syncSettlementBillLedger(
|
||||
Player $player,
|
||||
int $billId,
|
||||
string $reason,
|
||||
int $cumulativeMinor,
|
||||
): int {
|
||||
$existing = DB::table('credit_ledger')
|
||||
->where('owner_type', 'player')
|
||||
->where('owner_id', $player->id)
|
||||
->where('reason', $reason)
|
||||
->where('ref_type', 'settlement_bill')
|
||||
->where('ref_id', $billId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$recorded = $existing !== null ? (int) $existing->amount : 0;
|
||||
$delta = $cumulativeMinor - $recorded;
|
||||
if ($delta <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 幂等闸门:credit_ledger (settlement_bill, settlement_payout) 唯一;重复入账直接返回。
|
||||
try {
|
||||
$now = now();
|
||||
if ($existing === null) {
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => $amountMinor,
|
||||
'reason' => 'settlement_payout',
|
||||
'amount' => $cumulativeMinor,
|
||||
'reason' => $reason,
|
||||
'ref_type' => 'settlement_bill',
|
||||
'ref_id' => $billId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
if ($this->isUniqueViolation($e)) {
|
||||
return;
|
||||
}
|
||||
throw $e;
|
||||
} else {
|
||||
DB::table('credit_ledger')
|
||||
->where('id', (int) $existing->id)
|
||||
->update([
|
||||
'amount' => $cumulativeMinor,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $delta;
|
||||
}
|
||||
|
||||
private function decreaseUsedCredit(Player $player, int $amountMinor): void
|
||||
|
||||
@@ -44,11 +44,17 @@ e2e/
|
||||
│ │ ├── 13.credit-settlement-win.spec.ts # 信用盘中奖释额
|
||||
│ │ ├── 14.settlement-payment.spec.ts # 账期 confirm + 收付
|
||||
│ │ ├── 15.reconcile-job.spec.ts # pending_reconcile 扫描
|
||||
│ │ ├── 16.agent-share-bill.spec.ts # 代理占成账单
|
||||
│ │ ├── 17.settlement-partial-payment.spec.ts # 部分收付
|
||||
│ │ ├── 18.settlement-permissions.spec.ts # 站点财务/代理权限
|
||||
│ │ ├── 19.settlement-bad-debt-partial.spec.ts # 部分收付后坏账
|
||||
│ │ ├── _helper.ts # 共享步骤
|
||||
│ │ └── helpers/draw-settlement.ts # 结算流水线
|
||||
│ └── ui/
|
||||
│ ├── admin-login.spec.ts
|
||||
│ └── front-login-hall.spec.ts
|
||||
│ ├── admin-settlement-center.spec.ts
|
||||
│ ├── front-login-hall.spec.ts
|
||||
│ └── front-place-bet.spec.ts
|
||||
├── database/seeders/
|
||||
│ └── E2EPlayerSeeder.php # 建可登录玩家(E2E\Seeders 命名空间)
|
||||
├── routes/e2e.php # 仅 /api/v1/_e2e/* 路由
|
||||
@@ -98,8 +104,8 @@ composer.json # autoload-dev 加 E2E\Seeders\, E2E\Pr
|
||||
6. **`php artisan lottery:db-init --fresh`** ⚠️ 重建 `lottery_e2e` 库(**只**作用于此库,绝不碰其他库)
|
||||
7. 跑 `E2EPlayerSeeder`(建可登录玩家)
|
||||
8. 起 `php artisan serve`(8000)、`queue:work redis`(默认队列)、`reverb:start`(8080)
|
||||
9. `npx playwright install chromium`(首次)
|
||||
10. `npx playwright test`
|
||||
9. UI 测试默认用本机 **Google Chrome**(`playwright.config.ts` `channel: 'chrome'`),**无需**下载 Playwright Chromium
|
||||
10. `npx playwright test`(若要用自带 Chromium:`PLAYWRIGHT_USE_BUNDLED_CHROMIUM=1 ./e2e/scripts/run.sh`)
|
||||
|
||||
跑完按 Ctrl+C 自动停 serve/queue/reverb。`docker compose down -v` 自行决定(脚本不删 volume,下次跑快)。
|
||||
|
||||
@@ -144,7 +150,7 @@ PLAYWRIGHT_API_URL=http://127.0.0.1:8000 \
|
||||
| 玩家登录 / 失败 / 锁定 | 02.player-auth | 4 | ✅ |
|
||||
| 玩家钱包 + 下注 + 幂等 | 03.wallet-ticket | 3 | ✅ |
|
||||
| 超管登录 / dashboard / 401 | 04.admin-auth | 3 | ✅ |
|
||||
| 玩家 transfer-in/out + 1001/1010 | 05.wallet-transfer | 7 (+2 skip) | ✅ |
|
||||
| 玩家 transfer-in/out + 1001/1010 | 05.wallet-transfer | 7 | ✅ |
|
||||
| 钱包流水一致性 + 过滤 + 分页 | 06.wallet-logs | 4 | ✅ |
|
||||
| 超管创建/冻结/解冻/查玩家 | 07.admin-player | 6 | ✅ |
|
||||
| 开奖+结算+派彩 完整链路 | 08.draw-publish-settle | 1 | ✅ 确定性 poll |
|
||||
@@ -155,10 +161,16 @@ PLAYWRIGHT_API_URL=http://127.0.0.1:8000 \
|
||||
| 信用盘中奖释额 game_settlement_win | 13.credit-settlement-win | 1 | ✅ |
|
||||
| 账期 confirm + 登记收付闭环 | 14.settlement-payment | 1 | ✅ |
|
||||
| pending_reconcile → reconcile-jobs | 15.reconcile-job | 1 | ✅ |
|
||||
| 代理占成账单 share_profit | 16.agent-share-bill | 1 | ✅ |
|
||||
| 部分收付 partial_paid → settled | 17.settlement-partial-payment | 2 | ✅ |
|
||||
| 站点财务收付 / 绑定代理禁坏账 | 18.settlement-permissions | 1 | ✅ |
|
||||
| 部分收付后坏账核销 | 19.settlement-bad-debt-partial | 1 | ✅ |
|
||||
| 管理端 UI 登录 | ui/admin-login | 1 | ✅ |
|
||||
| 管理端 UI 结算中心 | ui/admin-settlement-center | 1 | ✅ |
|
||||
| 玩家端 UI 登录进大厅 | ui/front-login-hall | 1 | ✅ |
|
||||
| 玩家端 UI 下注提交 | ui/front-place-bet | 1 | ✅ |
|
||||
|
||||
合计 ~36 个用例覆盖 e2e 关键链路。
|
||||
合计 ~43 个用例覆盖 e2e 关键链路。
|
||||
|
||||
## 已知未覆盖(需外部依赖或重构成本高)
|
||||
|
||||
@@ -194,15 +206,20 @@ npx playwright test 05.wallet-transfer.spec.ts --headed
|
||||
- **测试卡住**:脚本的 `cleanup` 钩子 Ctrl+C 会停 artisan/queue/reverb
|
||||
- **player 登录 401**:`curl -X POST http://127.0.0.1:8000/api/v1/_e2e/reset-player` 重置玩家
|
||||
|
||||
## CI 集成(参考)
|
||||
## CI 集成
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e.yml
|
||||
- name: e2e
|
||||
run: ./e2e/scripts/run.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: e2e-artifacts
|
||||
path: e2e/artifacts/
|
||||
仓库已包含 `.github/workflows/e2e.yml`(push/PR 自动跑 API 项目;`E2E_UI=0` 不启前后端 dev server)。
|
||||
|
||||
本地跑全量(含 UI):
|
||||
|
||||
```bash
|
||||
./e2e/scripts/run.sh
|
||||
```
|
||||
|
||||
仅 API(与 CI 一致):
|
||||
|
||||
```bash
|
||||
E2E_UI=0 ./e2e/scripts/run.sh
|
||||
```
|
||||
|
||||
失败产物:`e2e/artifacts/`、`e2e/logs/`。
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace E2E\Seeders;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Services\Agent\AgentNodeService;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -28,6 +30,7 @@ final class E2EPlayerSeeder extends Seeder
|
||||
$mockWalletPort = (string) env('E2E_MOCK_WALLET_PORT', '5555');
|
||||
|
||||
$this->ensureDemoIntegrationSite($siteCode, $currency, $mockWalletPort);
|
||||
$this->ensureE2ELeafAgent($siteCode, $password);
|
||||
|
||||
// LocalDemoSeeder 可能已建无 password_hash 的 demo_player;合并为一条可登录行。
|
||||
Player::query()
|
||||
@@ -135,4 +138,42 @@ final class E2EPlayerSeeder extends Seeder
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureE2ELeafAgent(string $siteCode, string $password): void
|
||||
{
|
||||
$siteId = (int) AdminSite::query()->where('code', $siteCode)->value('id');
|
||||
if ($siteId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = DB::table('admin_users')->where('username', 'e2e_leaf_agent')->exists();
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootId = (int) DB::table('agent_nodes')
|
||||
->where('admin_site_id', $siteId)
|
||||
->where('depth', 0)
|
||||
->value('id');
|
||||
if ($rootId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$super = AdminUser::query()->where('username', 'admin')->first();
|
||||
if ($super === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(AgentNodeService::class)->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'e2e_leaf',
|
||||
'name' => 'E2E Leaf Agent',
|
||||
'username' => 'e2e_leaf_agent',
|
||||
'password' => $password,
|
||||
'total_share_rate' => 25,
|
||||
'credit_limit' => 200_000,
|
||||
'rebate_limit' => 0.01,
|
||||
'default_player_rebate' => 0.005,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ const ADMIN_URL = process.env.PLAYWRIGHT_ADMIN_URL ?? 'http://localhost:3801';
|
||||
const FRONT_URL = process.env.PLAYWRIGHT_FRONT_URL ?? 'http://localhost:3800';
|
||||
const ARTIFACT_DIR = 'artifacts';
|
||||
|
||||
/** 默认用本机 Google Chrome;设 PLAYWRIGHT_USE_BUNDLED_CHROMIUM=1 则走 ms-playwright 自带 Chromium。 */
|
||||
const uiBrowserUse = {
|
||||
...devices['Desktop Chrome'],
|
||||
...(process.env.PLAYWRIGHT_USE_BUNDLED_CHROMIUM === '1'
|
||||
? {}
|
||||
: { channel: 'chrome' as const }),
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
globalSetup: './global-setup.ts',
|
||||
@@ -40,7 +48,7 @@ export default defineConfig({
|
||||
testMatch: 'tests/ui/admin*.spec.ts',
|
||||
use: {
|
||||
baseURL: ADMIN_URL,
|
||||
...devices['Desktop Chrome'],
|
||||
...uiBrowserUse,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -48,7 +56,7 @@ export default defineConfig({
|
||||
testMatch: 'tests/ui/front*.spec.ts',
|
||||
use: {
|
||||
baseURL: FRONT_URL,
|
||||
...devices['Desktop Chrome'],
|
||||
...uiBrowserUse,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -65,6 +65,10 @@ Route::prefix('api/v1/_e2e')
|
||||
// 信用盘 / SSO / 主站钱包 mock
|
||||
Route::post('credit-player/setup', [E2EProvisionController::class, 'setupCreditPlayer'])
|
||||
->name('e2e.credit-player.setup');
|
||||
Route::post('site-operator/setup', [E2EProvisionController::class, 'setupSiteOperator'])
|
||||
->name('e2e.site-operator.setup');
|
||||
Route::post('settlement/reset', [E2EProvisionController::class, 'resetSiteSettlement'])
|
||||
->name('e2e.settlement.reset');
|
||||
Route::post('sso/mint-jwt', [E2EProvisionController::class, 'mintSsoJwt'])
|
||||
->name('e2e.sso.mint-jwt');
|
||||
Route::post('site/wallet-api', [E2EProvisionController::class, 'configureWalletApi'])
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# 4. php artisan serve 起应用
|
||||
# 5. 启动 queue:work(异步开奖/广播)
|
||||
# 6. 启动 reverb:start
|
||||
# 7. npx playwright install chromium(首次)
|
||||
# 7. npx playwright install chromium(仅缺浏览器缓存时)
|
||||
# 8. npx playwright test
|
||||
# 9. 失败时打 docker logs + Laravel 日志;结束时清理 compose
|
||||
|
||||
@@ -49,6 +49,24 @@ require php
|
||||
require node
|
||||
require npx
|
||||
|
||||
playwright_cache_dir() {
|
||||
case "$(uname -s)" in
|
||||
Darwin) echo "$HOME/Library/Caches/ms-playwright" ;;
|
||||
Linux) echo "${XDG_CACHE_HOME:-$HOME/.cache}/ms-playwright" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) echo "${LOCALAPPDATA:-$HOME/AppData/Local}/ms-playwright" ;;
|
||||
*) echo "${XDG_CACHE_HOME:-$HOME/.cache}/ms-playwright" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
playwright_chromium_cached() {
|
||||
local cache_dir
|
||||
cache_dir="$(playwright_cache_dir)"
|
||||
# UI 项目依赖 chromium_headless_shell;API 仅 request 时可能不触发,但一并检测。
|
||||
compgen -G "${cache_dir}/chromium-"* >/dev/null 2>&1 \
|
||||
|| compgen -G "${cache_dir}/chromium_headless_shell-"* >/dev/null 2>&1 \
|
||||
|| [[ -d "$E2E_DIR/node_modules/playwright-core/.local-browsers" ]]
|
||||
}
|
||||
|
||||
echo "==> [1/8] docker compose up -d"
|
||||
if ! docker image inspect postgres:16-alpine >/dev/null 2>&1 \
|
||||
|| ! docker image inspect redis:7-alpine >/dev/null 2>&1; then
|
||||
@@ -224,22 +242,31 @@ cd "$E2E_DIR"
|
||||
if [[ ! -d node_modules ]]; then
|
||||
npm install
|
||||
fi
|
||||
if [[ ! -d node_modules/@playwright/test/.local-browsers ]]; then
|
||||
npx playwright install chromium
|
||||
# UI 默认 channel:chrome(本机 Google Chrome);仅显式要求 bundled 时才下载 ms-playwright Chromium。
|
||||
if [[ "${PLAYWRIGHT_USE_BUNDLED_CHROMIUM:-0}" == "1" ]]; then
|
||||
if ! playwright_chromium_cached; then
|
||||
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$(playwright_cache_dir)}"
|
||||
npx playwright install chromium
|
||||
else
|
||||
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$(playwright_cache_dir)}"
|
||||
fi
|
||||
fi
|
||||
|
||||
PLAYWRIGHT_JWT_SECRET=$(grep '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE" | cut -d= -f2-) \
|
||||
PLAYWRIGHT_API_URL="$API_URL" \
|
||||
PLAYWRIGHT_ADMIN_URL="http://localhost:3801" \
|
||||
PLAYWRIGHT_FRONT_URL="http://localhost:3800" \
|
||||
REVERB_APP_KEY=$(grep '^REVERB_APP_KEY=' "$ENV_FILE" | cut -d= -f2-) \
|
||||
REVERB_HOST=127.0.0.1 \
|
||||
REVERB_PORT=8080 \
|
||||
E2E_MOCK_WALLET_PORT=5555 \
|
||||
E2E_ADMIN_USERNAME=admin \
|
||||
E2E_ADMIN_PASSWORD=12345678 \
|
||||
E2E_PLAYER_USERNAME=$(grep '^E2E_PLAYER_USERNAME=' "$ENV_E2E" | cut -d= -f2-) \
|
||||
E2E_PLAYER_PASSWORD=$(grep '^E2E_PLAYER_PASSWORD=' "$ENV_E2E" | cut -d= -f2-) \
|
||||
export PLAYWRIGHT_JWT_SECRET="$(grep '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE" | cut -d= -f2-)"
|
||||
export PLAYWRIGHT_API_URL="$API_URL"
|
||||
export PLAYWRIGHT_ADMIN_URL="http://localhost:3801"
|
||||
export PLAYWRIGHT_FRONT_URL="http://localhost:3800"
|
||||
export REVERB_APP_KEY="$(grep '^REVERB_APP_KEY=' "$ENV_FILE" | cut -d= -f2-)"
|
||||
export REVERB_HOST=127.0.0.1
|
||||
export REVERB_PORT=8080
|
||||
export E2E_MOCK_WALLET_PORT=5555
|
||||
export E2E_ADMIN_USERNAME=admin
|
||||
export E2E_ADMIN_PASSWORD=12345678
|
||||
export E2E_PLAYER_USERNAME="$(grep '^E2E_PLAYER_USERNAME=' "$ENV_E2E" | cut -d= -f2-)"
|
||||
export E2E_PLAYER_PASSWORD="$(grep '^E2E_PLAYER_PASSWORD=' "$ENV_E2E" | cut -d= -f2-)"
|
||||
if [[ "${E2E_UI:-1}" != "1" ]]; then
|
||||
set -- --project=api "$@"
|
||||
fi
|
||||
npx playwright test "$@"
|
||||
rc=$?
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { test, expect, request as pwRequest } from '@playwright/test';
|
||||
import { playerLoginViaBypass, resetE2EPlayer, fetchCurrentDrawNo } from './_helper';
|
||||
import { playerLoginViaBypass, resetE2EPlayer, fetchOpenDrawNo } from './_helper';
|
||||
|
||||
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
|
||||
const BET_AMOUNT_MINOR = 1000;
|
||||
@@ -39,8 +39,7 @@ test('玩家登录 → /wallet/balance 返回币种 + 余额', async () => {
|
||||
});
|
||||
|
||||
test('preview → place → 拿 ticket_no + 余额变动', async () => {
|
||||
const draw = await fetchCurrentDrawNo();
|
||||
test.skip(draw === null, '当前无开放期号(draw.current 返回 null),跳过下注链路');
|
||||
const drawNo = await fetchOpenDrawNo();
|
||||
|
||||
const session = await playerLoginViaBypass();
|
||||
const ctx = await pwRequest.newContext({
|
||||
@@ -52,7 +51,7 @@ test('preview → place → 拿 ticket_no + 余额变动', async () => {
|
||||
|
||||
const preview = await ctx.post('/api/v1/ticket/preview', {
|
||||
data: {
|
||||
draw_id: draw!.draw_no,
|
||||
draw_id: drawNo,
|
||||
currency_code: CURRENCY,
|
||||
client_trace_id: `e2e-preview-${Date.now()}`,
|
||||
lines: [{ number: '1234', play_code: 'straight', amount: BET_AMOUNT_MINOR }],
|
||||
@@ -64,7 +63,7 @@ test('preview → place → 拿 ticket_no + 余额变动', async () => {
|
||||
|
||||
const place = await ctx.post('/api/v1/ticket/place', {
|
||||
data: {
|
||||
draw_id: draw!.draw_no,
|
||||
draw_id: drawNo,
|
||||
currency_code: CURRENCY,
|
||||
client_trace_id: `e2e-place-${Date.now()}`,
|
||||
lines: [{ number: '1234', play_code: 'straight', amount: BET_AMOUNT_MINOR }],
|
||||
@@ -89,8 +88,7 @@ test('preview → place → 拿 ticket_no + 余额变动', async () => {
|
||||
});
|
||||
|
||||
test('同 client_trace_id 第二次 place → 幂等回放,不重复扣款', async () => {
|
||||
const draw = await fetchCurrentDrawNo();
|
||||
test.skip(draw === null, '当前无开放期号,跳过幂等用例');
|
||||
const drawNo = await fetchOpenDrawNo();
|
||||
|
||||
const session = await playerLoginViaBypass();
|
||||
const ctx = await pwRequest.newContext({
|
||||
@@ -99,7 +97,7 @@ test('同 client_trace_id 第二次 place → 幂等回放,不重复扣款', a
|
||||
|
||||
const trace = `e2e-idem-${Date.now()}`;
|
||||
const payload = {
|
||||
draw_id: draw!.draw_no,
|
||||
draw_id: drawNo,
|
||||
currency_code: CURRENCY,
|
||||
client_trace_id: trace,
|
||||
lines: [{ number: '5678', play_code: 'straight', amount: 500 }],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 5. 余额不足 1001:把 balance 改到 0 → transfer-out → 1001
|
||||
* 6. 幂等冲突 1010:同 idempotent_key 第二次 amount 不同 → 1010
|
||||
*
|
||||
* 不覆盖(需外部主站 mock 才能跑,留 skip + 文档):
|
||||
* 不覆盖(需外部主站 mock 或改配置才能跑):
|
||||
* - 主站失败 1009(需真主站返 5xx)
|
||||
* - 主站超时 504/408 → 1002 pending_reconcile(需真主站返 timeout)
|
||||
* - 转入关 1004(需 .env 关 transfer_in_enabled,跑前要改)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
adminCtxOf,
|
||||
fetchOpenDrawNo,
|
||||
playerLoginViaBypass,
|
||||
openSettlementPeriod,
|
||||
setupCreditPlayer,
|
||||
} from './_helper';
|
||||
import { runWalletDrawSettlement } from './helpers/draw-settlement';
|
||||
@@ -20,21 +21,7 @@ test('信用下注结算后关账 → 生成玩家 settlement_bill', async () =>
|
||||
|
||||
const adminSession = await adminLoginViaBypass();
|
||||
const admin = await adminCtxOf(adminSession.accessToken);
|
||||
|
||||
const start = new Date(Date.now() - 3600_000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
const end = new Date(Date.now() + 3600_000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
|
||||
const open = await admin.post('/api/v1/admin/settlement-periods', {
|
||||
data: {
|
||||
admin_site_id: credit.admin_site_id,
|
||||
period_start: start,
|
||||
period_end: end,
|
||||
},
|
||||
});
|
||||
expect(open.ok(), `open period status=${open.status()}`).toBeTruthy();
|
||||
const openBody = (await open.json()) as any;
|
||||
expect(openBody.code).toBe(0);
|
||||
const periodId = openBody.data.id;
|
||||
const periodId = await openSettlementPeriod(admin, credit.admin_site_id, credit.site_code);
|
||||
|
||||
const playerSession = await playerLoginViaBypass({
|
||||
site_code: credit.site_code,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
adminCtxOf,
|
||||
fetchOpenDrawNo,
|
||||
playerLoginViaBypass,
|
||||
openSettlementPeriod,
|
||||
setupCreditPlayer,
|
||||
} from './_helper';
|
||||
import { runWalletDrawSettlement } from './helpers/draw-settlement';
|
||||
@@ -24,21 +25,7 @@ test('关账后 confirm + 全额收付 → 玩家账单 settled + payment_record
|
||||
|
||||
const adminSession = await adminLoginViaBypass();
|
||||
const admin = await adminCtxOf(adminSession.accessToken);
|
||||
|
||||
const start = new Date(Date.now() - 3600_000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
const end = new Date(Date.now() + 3600_000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
|
||||
const open = await admin.post('/api/v1/admin/settlement-periods', {
|
||||
data: {
|
||||
admin_site_id: credit.admin_site_id,
|
||||
period_start: start,
|
||||
period_end: end,
|
||||
},
|
||||
});
|
||||
expect(open.ok(), `open period status=${open.status()}`).toBeTruthy();
|
||||
const openBody = (await open.json()) as any;
|
||||
expect(openBody.code).toBe(0);
|
||||
const periodId = openBody.data.id;
|
||||
const periodId = await openSettlementPeriod(admin, credit.admin_site_id);
|
||||
|
||||
const playerSession = await playerLoginViaBypass({
|
||||
site_code: credit.site_code,
|
||||
|
||||
79
e2e/tests/api/16.agent-share-bill.spec.ts
Normal file
79
e2e/tests/api/16.agent-share-bill.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* E2E:信用下注结算后关账 → 玩家账单 + 代理占成账单(share_profit)。
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminLoginViaBypass,
|
||||
adminCtxOf,
|
||||
fetchOpenDrawNo,
|
||||
playerLoginViaBypass,
|
||||
openSettlementPeriod,
|
||||
setupCreditPlayer,
|
||||
} from './_helper';
|
||||
import { runWalletDrawSettlement } from './helpers/draw-settlement';
|
||||
|
||||
test('关账后生成玩家账单与代理占成账单', async () => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
const credit = await setupCreditPlayer({
|
||||
credit_limit: 100_000,
|
||||
username: `e2e_share_${Date.now()}`,
|
||||
});
|
||||
const drawNo = await fetchOpenDrawNo();
|
||||
|
||||
const adminSession = await adminLoginViaBypass();
|
||||
const admin = await adminCtxOf(adminSession.accessToken);
|
||||
const periodId = await openSettlementPeriod(admin, credit.admin_site_id);
|
||||
|
||||
const playerSession = await playerLoginViaBypass({
|
||||
site_code: credit.site_code,
|
||||
username: credit.username,
|
||||
password: credit.password,
|
||||
});
|
||||
|
||||
await runWalletDrawSettlement({
|
||||
adminToken: adminSession.accessToken,
|
||||
playerToken: playerSession.access_token,
|
||||
drawNo,
|
||||
betNumber: '5678',
|
||||
betAmount: 10_000,
|
||||
expectWalletIncrease: false,
|
||||
});
|
||||
|
||||
const close = await admin.post(`/api/v1/admin/settlement-periods/${periodId}/close`);
|
||||
expect(close.ok(), `close period status=${close.status()}`).toBeTruthy();
|
||||
const closeBody = (await close.json()) as any;
|
||||
expect(closeBody.code).toBe(0);
|
||||
|
||||
const bills = await admin.get(
|
||||
`/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=50`,
|
||||
);
|
||||
const billsBody = (await bills.json()) as any;
|
||||
expect(billsBody.code).toBe(0);
|
||||
|
||||
const playerBill = billsBody.data.items.find(
|
||||
(b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id,
|
||||
);
|
||||
expect(playerBill, '应有该信用玩家的账单').toBeTruthy();
|
||||
|
||||
const agentBills = billsBody.data.items.filter((b: any) => b.bill_type === 'agent');
|
||||
expect(agentBills.length, '关账后应生成代理占成账单').toBeGreaterThan(0);
|
||||
|
||||
const leafEdge = agentBills.find(
|
||||
(b: any) =>
|
||||
Number(b.owner_id) === credit.agent_node_id || Number(b.counterparty_id) === credit.agent_node_id,
|
||||
);
|
||||
expect(leafEdge, '应包含直属代理节点的占成边').toBeTruthy();
|
||||
|
||||
const meta =
|
||||
typeof leafEdge.meta_json === 'string'
|
||||
? JSON.parse(leafEdge.meta_json)
|
||||
: leafEdge.meta_json ?? {};
|
||||
expect(
|
||||
'share_profit' in meta || Number(leafEdge.net_amount) !== 0,
|
||||
'代理账单应含占成信息或非零净额',
|
||||
).toBeTruthy();
|
||||
|
||||
await admin.dispose();
|
||||
});
|
||||
202
e2e/tests/api/17.settlement-partial-payment.spec.ts
Normal file
202
e2e/tests/api/17.settlement-partial-payment.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* E2E:账期账单部分收付 → partial_paid → 尾款结清 settled。
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminLoginViaBypass,
|
||||
adminCtxOf,
|
||||
fetchBillCreditLedgerRows,
|
||||
fetchOpenDrawNo,
|
||||
playerLoginViaBypass,
|
||||
openSettlementPeriod,
|
||||
setupCreditPlayer,
|
||||
} from './_helper';
|
||||
import { runWalletDrawSettlement } from './helpers/draw-settlement';
|
||||
|
||||
test('confirm 后分两笔收付 → partial_paid → settled', async () => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
const credit = await setupCreditPlayer({
|
||||
credit_limit: 100_000,
|
||||
username: `e2e_partial_${Date.now()}`,
|
||||
});
|
||||
const drawNo = await fetchOpenDrawNo();
|
||||
|
||||
const adminSession = await adminLoginViaBypass();
|
||||
const admin = await adminCtxOf(adminSession.accessToken);
|
||||
const periodId = await openSettlementPeriod(admin, credit.admin_site_id);
|
||||
|
||||
const playerSession = await playerLoginViaBypass({
|
||||
site_code: credit.site_code,
|
||||
username: credit.username,
|
||||
password: credit.password,
|
||||
});
|
||||
|
||||
await runWalletDrawSettlement({
|
||||
adminToken: adminSession.accessToken,
|
||||
playerToken: playerSession.access_token,
|
||||
drawNo,
|
||||
betNumber: '1234',
|
||||
publishWinNumber: '9999',
|
||||
betAmount: 10_000,
|
||||
expectWalletIncrease: false,
|
||||
});
|
||||
|
||||
const close = await admin.post(`/api/v1/admin/settlement-periods/${periodId}/close`);
|
||||
expect(close.ok()).toBeTruthy();
|
||||
expect((await close.json()).code).toBe(0);
|
||||
|
||||
const bills = await admin.get(
|
||||
`/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`,
|
||||
);
|
||||
const billsBody = (await bills.json()) as any;
|
||||
const playerBill = billsBody.data.items.find(
|
||||
(b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id,
|
||||
);
|
||||
expect(playerBill).toBeTruthy();
|
||||
expect(Number(playerBill.net_amount)).toBeGreaterThan(0);
|
||||
|
||||
const confirm = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/confirm`);
|
||||
expect(confirm.ok()).toBeTruthy();
|
||||
const confirmBody = (await confirm.json()) as any;
|
||||
expect(confirmBody.code).toBe(0);
|
||||
expect(confirmBody.data.status).toBe('confirmed');
|
||||
|
||||
const billShow = await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`);
|
||||
const billDetail = (await billShow.json()) as any;
|
||||
const unpaid = Math.abs(Number(billDetail.data.bill.unpaid_amount));
|
||||
expect(unpaid).toBeGreaterThan(1);
|
||||
const firstPay = Math.floor(unpaid / 2);
|
||||
expect(firstPay).toBeGreaterThan(0);
|
||||
|
||||
const pay1 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, {
|
||||
data: { amount: firstPay, method: 'e2e_partial_1', remark: 'e2e first half' },
|
||||
});
|
||||
expect(pay1.ok()).toBeTruthy();
|
||||
const pay1Body = (await pay1.json()) as any;
|
||||
expect(pay1Body.code).toBe(0);
|
||||
expect(pay1Body.data.bill.status).toBe('partial_paid');
|
||||
|
||||
const ledgerAfterPay1 = await fetchBillCreditLedgerRows(
|
||||
credit.player_id,
|
||||
Number(playerBill.id),
|
||||
'settlement_confirm',
|
||||
);
|
||||
expect(ledgerAfterPay1).toHaveLength(1);
|
||||
expect(Number(ledgerAfterPay1[0].amount)).toBe(firstPay);
|
||||
|
||||
const midShow = await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`);
|
||||
const midDetail = (await midShow.json()) as any;
|
||||
const secondPay = Math.abs(Number(midDetail.data.bill.unpaid_amount));
|
||||
expect(secondPay).toBeGreaterThan(0);
|
||||
|
||||
const pay2 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, {
|
||||
data: { amount: secondPay, method: 'e2e_partial_2', remark: 'e2e remainder' },
|
||||
});
|
||||
const pay2Text = await pay2.text();
|
||||
expect(pay2.ok(), `pay2 status=${pay2.status()} body=${pay2Text.slice(0, 400)}`).toBeTruthy();
|
||||
const pay2Body = JSON.parse(pay2Text) as any;
|
||||
expect(pay2Body.data.bill.status).toBe('settled');
|
||||
expect(Number(pay2Body.data.bill.unpaid_amount)).toBe(0);
|
||||
|
||||
const ledgerAfterPay2 = await fetchBillCreditLedgerRows(
|
||||
credit.player_id,
|
||||
Number(playerBill.id),
|
||||
'settlement_confirm',
|
||||
);
|
||||
expect(ledgerAfterPay2).toHaveLength(1);
|
||||
expect(Number(ledgerAfterPay2[0].amount)).toBe(unpaid);
|
||||
|
||||
const exceed = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, {
|
||||
data: { amount: 1, method: 'e2e_over', remark: 'should fail' },
|
||||
});
|
||||
const exceedBody = (await exceed.json()) as any;
|
||||
expect(exceedBody.code).not.toBe(0);
|
||||
|
||||
await admin.dispose();
|
||||
});
|
||||
|
||||
test('玩家赢单 confirm 后分两笔收付 → settlement_payout 累计', async () => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
const winNumber = `6${String(Date.now()).slice(-3)}`;
|
||||
const credit = await setupCreditPlayer({
|
||||
credit_limit: 100_000,
|
||||
username: `e2e_partial_win_${Date.now()}`,
|
||||
});
|
||||
const drawNo = await fetchOpenDrawNo();
|
||||
|
||||
const adminSession = await adminLoginViaBypass();
|
||||
const admin = await adminCtxOf(adminSession.accessToken);
|
||||
const periodId = await openSettlementPeriod(admin, credit.admin_site_id);
|
||||
|
||||
const playerSession = await playerLoginViaBypass({
|
||||
site_code: credit.site_code,
|
||||
username: credit.username,
|
||||
password: credit.password,
|
||||
});
|
||||
|
||||
await runWalletDrawSettlement({
|
||||
adminToken: adminSession.accessToken,
|
||||
playerToken: playerSession.access_token,
|
||||
drawNo,
|
||||
betNumber: winNumber,
|
||||
betAmount: 10_000,
|
||||
expectWalletIncrease: false,
|
||||
});
|
||||
|
||||
const close = await admin.post(`/api/v1/admin/settlement-periods/${periodId}/close`);
|
||||
expect(close.ok()).toBeTruthy();
|
||||
|
||||
const bills = await admin.get(
|
||||
`/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`,
|
||||
);
|
||||
const billsBody = (await bills.json()) as any;
|
||||
const playerBill = billsBody.data.items.find(
|
||||
(b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id,
|
||||
);
|
||||
expect(playerBill).toBeTruthy();
|
||||
expect(Number(playerBill.net_amount)).toBeLessThan(0);
|
||||
|
||||
await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/confirm`);
|
||||
|
||||
const billDetail = (await (await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`)).json()) as any;
|
||||
const unpaid = Math.abs(Number(billDetail.data.bill.unpaid_amount));
|
||||
expect(unpaid).toBeGreaterThan(1);
|
||||
const firstPay = Math.floor(unpaid / 2);
|
||||
expect(firstPay).toBeGreaterThan(0);
|
||||
|
||||
const pay1 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, {
|
||||
data: { amount: firstPay, method: 'e2e_win_partial_1', remark: 'win first half' },
|
||||
});
|
||||
expect(pay1.ok()).toBeTruthy();
|
||||
expect((await pay1.json()).data.bill.status).toBe('partial_paid');
|
||||
|
||||
const ledgerAfterPay1 = await fetchBillCreditLedgerRows(
|
||||
credit.player_id,
|
||||
Number(playerBill.id),
|
||||
'settlement_payout',
|
||||
);
|
||||
expect(ledgerAfterPay1).toHaveLength(1);
|
||||
expect(Number(ledgerAfterPay1[0].amount)).toBe(firstPay);
|
||||
|
||||
const midDetail = (await (await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`)).json()) as any;
|
||||
const secondPay = Math.abs(Number(midDetail.data.bill.unpaid_amount));
|
||||
|
||||
const pay2 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, {
|
||||
data: { amount: secondPay, method: 'e2e_win_partial_2', remark: 'win remainder' },
|
||||
});
|
||||
expect(pay2.ok()).toBeTruthy();
|
||||
expect((await pay2.json()).data.bill.status).toBe('settled');
|
||||
|
||||
const ledgerAfterPay2 = await fetchBillCreditLedgerRows(
|
||||
credit.player_id,
|
||||
Number(playerBill.id),
|
||||
'settlement_payout',
|
||||
);
|
||||
expect(ledgerAfterPay2).toHaveLength(1);
|
||||
expect(Number(ledgerAfterPay2[0].amount)).toBe(unpaid);
|
||||
|
||||
await admin.dispose();
|
||||
});
|
||||
151
e2e/tests/api/18.settlement-permissions.spec.ts
Normal file
151
e2e/tests/api/18.settlement-permissions.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* E2E:结算收付权限 — 站点财务可收付/坏账;绑定代理不可坏账核销。
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminCtxOf,
|
||||
adminLoginWithAccount,
|
||||
adminLoginViaBypass,
|
||||
fetchOpenDrawNo,
|
||||
openSettlementPeriod,
|
||||
playerLoginViaBypass,
|
||||
setupCreditPlayer,
|
||||
setupSiteOperator,
|
||||
} from './_helper';
|
||||
import { runWalletDrawSettlement } from './helpers/draw-settlement';
|
||||
|
||||
const DEFAULT_PASS = process.env.E2E_PLAYER_PASSWORD ?? '12345678';
|
||||
|
||||
test('站点财务可 confirm + 收付;绑定代理不可坏账核销', async () => {
|
||||
test.setTimeout(360_000);
|
||||
|
||||
const credit = await setupCreditPlayer({
|
||||
credit_limit: 100_000,
|
||||
username: `e2e_perm_${Date.now()}`,
|
||||
});
|
||||
const finance = await setupSiteOperator({
|
||||
username: `e2e_fin_${Date.now()}`,
|
||||
password: DEFAULT_PASS,
|
||||
role_slug: 'site_finance',
|
||||
});
|
||||
const drawNo = await fetchOpenDrawNo();
|
||||
const winNumber = `5${String(Date.now()).slice(-3)}`;
|
||||
|
||||
const superSession = await adminLoginViaBypass();
|
||||
const superAdmin = await adminCtxOf(superSession.accessToken);
|
||||
const periodId = await openSettlementPeriod(superAdmin, credit.admin_site_id);
|
||||
|
||||
const playerSession = await playerLoginViaBypass({
|
||||
site_code: credit.site_code,
|
||||
username: credit.username,
|
||||
password: credit.password,
|
||||
});
|
||||
|
||||
await runWalletDrawSettlement({
|
||||
adminToken: superSession.accessToken,
|
||||
playerToken: playerSession.access_token,
|
||||
drawNo,
|
||||
betNumber: winNumber,
|
||||
betAmount: 10_000,
|
||||
expectWalletIncrease: false,
|
||||
});
|
||||
|
||||
const close = await superAdmin.post(`/api/v1/admin/settlement-periods/${periodId}/close`);
|
||||
expect(close.ok()).toBeTruthy();
|
||||
|
||||
const bills = await superAdmin.get(
|
||||
`/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`,
|
||||
);
|
||||
const billsBody = (await bills.json()) as any;
|
||||
const playerBill = billsBody.data.items.find(
|
||||
(b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id,
|
||||
);
|
||||
expect(playerBill).toBeTruthy();
|
||||
const billId = Number(playerBill.id);
|
||||
const unpaid = Number(playerBill.unpaid_amount);
|
||||
expect(unpaid).toBeGreaterThan(0);
|
||||
|
||||
const financeSession = await adminLoginWithAccount(finance.username, finance.password);
|
||||
const financeAdmin = await adminCtxOf(financeSession.accessToken);
|
||||
|
||||
const me = await financeAdmin.get('/api/v1/admin/auth/me');
|
||||
expect(me.ok()).toBeTruthy();
|
||||
const meBody = (await me.json()) as any;
|
||||
expect(meBody.data.admin.account_kind).toBe('site_finance');
|
||||
|
||||
const confirm = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/confirm`);
|
||||
expect(confirm.ok(), `site_finance confirm status=${confirm.status()}`).toBeTruthy();
|
||||
expect((await confirm.json()).code).toBe(0);
|
||||
|
||||
const pay = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/payments`, {
|
||||
data: { amount: unpaid, method: 'e2e_finance', remark: 'site finance payment' },
|
||||
});
|
||||
expect(pay.ok(), `site_finance payment status=${pay.status()}`).toBeTruthy();
|
||||
expect((await pay.json()).data.bill.status).toBe('settled');
|
||||
|
||||
await financeAdmin.dispose();
|
||||
|
||||
const credit2 = await setupCreditPlayer({
|
||||
credit_limit: 100_000,
|
||||
username: `e2e_perm2_${Date.now()}`,
|
||||
});
|
||||
const periodId2 = await openSettlementPeriod(superAdmin, credit2.admin_site_id);
|
||||
const drawNo2 = await fetchOpenDrawNo();
|
||||
const player2 = await playerLoginViaBypass({
|
||||
site_code: credit2.site_code,
|
||||
username: credit2.username,
|
||||
password: credit2.password,
|
||||
});
|
||||
|
||||
await runWalletDrawSettlement({
|
||||
adminToken: superSession.accessToken,
|
||||
playerToken: player2.access_token,
|
||||
drawNo: drawNo2,
|
||||
betNumber: `4${String(Date.now()).slice(-3)}`,
|
||||
betAmount: 10_000,
|
||||
expectWalletIncrease: false,
|
||||
});
|
||||
|
||||
const close2 = await superAdmin.post(`/api/v1/admin/settlement-periods/${periodId2}/close`);
|
||||
expect(close2.ok()).toBeTruthy();
|
||||
|
||||
const bills2 = await superAdmin.get(
|
||||
`/api/v1/admin/settlement-bills?settlement_period_id=${periodId2}&size=20`,
|
||||
);
|
||||
const bill2 = ((await bills2.json()) as any).data.items.find(
|
||||
(b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit2.player_id,
|
||||
);
|
||||
expect(bill2).toBeTruthy();
|
||||
|
||||
await superAdmin.post(`/api/v1/admin/settlement-bills/${bill2.id}/confirm`);
|
||||
|
||||
const agentSession = await adminLoginWithAccount('e2e_leaf_agent', DEFAULT_PASS);
|
||||
const agentAdmin = await adminCtxOf(agentSession.accessToken);
|
||||
const agentMe = (await (await agentAdmin.get('/api/v1/admin/auth/me')).json()) as any;
|
||||
expect(agentMe.data.admin.account_kind).toBe('agent_operator');
|
||||
|
||||
const badDebt = await agentAdmin.post(
|
||||
`/api/v1/admin/settlement-bills/${bill2.id}/bad-debt-write-off`,
|
||||
{ data: { reason: 'e2e should deny' } },
|
||||
);
|
||||
expect([403, 404]).toContain(badDebt.status());
|
||||
|
||||
const financeBadDebt = await setupSiteOperator({
|
||||
username: `e2e_fin_bd_${Date.now()}`,
|
||||
password: DEFAULT_PASS,
|
||||
role_slug: 'site_finance',
|
||||
});
|
||||
const finance2 = await adminLoginWithAccount(financeBadDebt.username, DEFAULT_PASS);
|
||||
const finance2Ctx = await adminCtxOf(finance2.accessToken);
|
||||
const writeOff = await finance2Ctx.post(
|
||||
`/api/v1/admin/settlement-bills/${bill2.id}/bad-debt-write-off`,
|
||||
{ data: { reason: 'e2e uncollectible' } },
|
||||
);
|
||||
expect(writeOff.ok(), `site_finance bad debt status=${writeOff.status()}`).toBeTruthy();
|
||||
expect((await writeOff.json()).code).toBe(0);
|
||||
|
||||
await agentAdmin.dispose();
|
||||
await finance2Ctx.dispose();
|
||||
await superAdmin.dispose();
|
||||
});
|
||||
105
e2e/tests/api/19.settlement-bad-debt-partial.spec.ts
Normal file
105
e2e/tests/api/19.settlement-bad-debt-partial.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* E2E:部分收付后坏账核销 → settlement_confirm 累计释额至账单全额。
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminLoginViaBypass,
|
||||
adminCtxOf,
|
||||
adminLoginWithAccount,
|
||||
fetchBillCreditLedgerRows,
|
||||
fetchOpenDrawNo,
|
||||
openSettlementPeriod,
|
||||
playerLoginViaBypass,
|
||||
setupCreditPlayer,
|
||||
setupSiteOperator,
|
||||
} from './_helper';
|
||||
import { runWalletDrawSettlement } from './helpers/draw-settlement';
|
||||
|
||||
const DEFAULT_PASS = process.env.E2E_PLAYER_PASSWORD ?? '12345678';
|
||||
|
||||
test('部分收付后坏账核销 → credit_ledger 累计至全额', async () => {
|
||||
test.setTimeout(360_000);
|
||||
|
||||
const credit = await setupCreditPlayer({
|
||||
credit_limit: 100_000,
|
||||
username: `e2e_bd_partial_${Date.now()}`,
|
||||
});
|
||||
const finance = await setupSiteOperator({
|
||||
username: `e2e_fin_bd_${Date.now()}`,
|
||||
password: DEFAULT_PASS,
|
||||
role_slug: 'site_finance',
|
||||
});
|
||||
const drawNo = await fetchOpenDrawNo();
|
||||
|
||||
const superSession = await adminLoginViaBypass();
|
||||
const superAdmin = await adminCtxOf(superSession.accessToken);
|
||||
const periodId = await openSettlementPeriod(superAdmin, credit.admin_site_id);
|
||||
|
||||
const playerSession = await playerLoginViaBypass({
|
||||
site_code: credit.site_code,
|
||||
username: credit.username,
|
||||
password: credit.password,
|
||||
});
|
||||
|
||||
await runWalletDrawSettlement({
|
||||
adminToken: superSession.accessToken,
|
||||
playerToken: playerSession.access_token,
|
||||
drawNo,
|
||||
betNumber: '4321',
|
||||
publishWinNumber: '9999',
|
||||
betAmount: 10_000,
|
||||
expectWalletIncrease: false,
|
||||
});
|
||||
|
||||
const close = await superAdmin.post(`/api/v1/admin/settlement-periods/${periodId}/close`);
|
||||
expect(close.ok()).toBeTruthy();
|
||||
|
||||
const bills = await superAdmin.get(
|
||||
`/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`,
|
||||
);
|
||||
const billsBody = (await bills.json()) as any;
|
||||
const playerBill = billsBody.data.items.find(
|
||||
(b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id,
|
||||
);
|
||||
expect(playerBill).toBeTruthy();
|
||||
expect(Number(playerBill.net_amount)).toBeGreaterThan(0);
|
||||
|
||||
const billId = Number(playerBill.id);
|
||||
await superAdmin.post(`/api/v1/admin/settlement-bills/${billId}/confirm`);
|
||||
|
||||
const billDetail = (await (await superAdmin.get(`/api/v1/admin/settlement-bills/${billId}`)).json()) as any;
|
||||
const unpaid = Math.abs(Number(billDetail.data.bill.unpaid_amount));
|
||||
const firstPay = Math.floor(unpaid / 2);
|
||||
expect(firstPay).toBeGreaterThan(0);
|
||||
|
||||
const financeSession = await adminLoginWithAccount(finance.username, finance.password);
|
||||
const financeAdmin = await adminCtxOf(financeSession.accessToken);
|
||||
|
||||
const pay1 = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/payments`, {
|
||||
data: { amount: firstPay, method: 'e2e_bd_partial', remark: 'partial before write-off' },
|
||||
});
|
||||
expect(pay1.ok()).toBeTruthy();
|
||||
expect((await pay1.json()).data.bill.status).toBe('partial_paid');
|
||||
|
||||
const ledgerAfterPay1 = await fetchBillCreditLedgerRows(credit.player_id, billId, 'settlement_confirm');
|
||||
expect(ledgerAfterPay1).toHaveLength(1);
|
||||
expect(Number(ledgerAfterPay1[0].amount)).toBe(firstPay);
|
||||
|
||||
const writeOff = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/bad-debt-write-off`, {
|
||||
data: { reason: 'e2e partial then bad debt' },
|
||||
});
|
||||
expect(writeOff.ok(), `bad debt status=${writeOff.status()}`).toBeTruthy();
|
||||
expect((await writeOff.json()).code).toBe(0);
|
||||
|
||||
const settledBill = (await (await superAdmin.get(`/api/v1/admin/settlement-bills/${billId}`)).json()) as any;
|
||||
expect(settledBill.data.bill.status).toBe('settled');
|
||||
expect(Number(settledBill.data.bill.unpaid_amount)).toBe(0);
|
||||
|
||||
const ledgerAfterWriteOff = await fetchBillCreditLedgerRows(credit.player_id, billId, 'settlement_confirm');
|
||||
expect(ledgerAfterWriteOff).toHaveLength(1);
|
||||
expect(Number(ledgerAfterWriteOff[0].amount)).toBe(unpaid);
|
||||
|
||||
await financeAdmin.dispose();
|
||||
await superAdmin.dispose();
|
||||
});
|
||||
@@ -144,6 +144,93 @@ export async function setupCreditPlayer(opts?: { credit_limit?: number; username
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function setupSiteOperator(opts?: {
|
||||
role_slug?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}): Promise<{
|
||||
admin_user_id: number;
|
||||
username: string;
|
||||
password: string;
|
||||
role_slug: string;
|
||||
admin_site_id: number;
|
||||
site_code: string;
|
||||
}> {
|
||||
const data = await e2e<any>('POST', '/site-operator/setup', opts ?? {});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function adminLoginWithAccount(
|
||||
account: string,
|
||||
password: string,
|
||||
): Promise<ReturnType<typeof adminLogin>> {
|
||||
const captcha = await fetchAdminCaptcha();
|
||||
const ctx = await pwRequest.newContext();
|
||||
const resp = await ctx.post('/api/v1/admin/auth/login', {
|
||||
data: {
|
||||
account,
|
||||
password,
|
||||
captcha_key: captcha.captcha_key,
|
||||
captcha_code: 'LOTTERY_E2E_BYPASS',
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `admin login failed: ${resp.status()}`).toBeTruthy();
|
||||
const body = (await resp.json()) as { data: any };
|
||||
await ctx.dispose();
|
||||
if (!body.data?.token) throw new Error('admin login returned no token: ' + JSON.stringify(body));
|
||||
return {
|
||||
accessToken: body.data.token,
|
||||
tokenType: body.data.token_type ?? 'Bearer',
|
||||
expiresIn: 0,
|
||||
admin: body.data.admin,
|
||||
};
|
||||
}
|
||||
|
||||
/** 以当前时间为中心的 2h 账期窗口;开账前会清掉同站历史账期避免重叠。 */
|
||||
export function periodWindowIso(): { start: string; end: string } {
|
||||
const start = new Date(Date.now() - 3600_000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
const end = new Date(Date.now() + 3600_000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export async function resetSiteSettlement(siteCode?: string): Promise<void> {
|
||||
await e2e('POST', '/settlement/reset', { site_code: siteCode ?? process.env.E2E_PLAYER_SITE_CODE ?? 'demo' });
|
||||
}
|
||||
|
||||
export async function fetchBillCreditLedgerRows(
|
||||
playerId: number,
|
||||
billId: number,
|
||||
reason: 'settlement_confirm' | 'settlement_payout',
|
||||
): Promise<Array<{ id: number; amount: number; reason: string; ref_type: string; ref_id: number }>> {
|
||||
const ledger = await e2e<any>('GET', `/inspect/credit-ledger?player_id=${playerId}&limit=50`);
|
||||
return (ledger.data.rows as any[]).filter(
|
||||
(row) =>
|
||||
row.reason === reason &&
|
||||
row.ref_type === 'settlement_bill' &&
|
||||
Number(row.ref_id) === billId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSettlementPeriod(
|
||||
admin: APIRequestContext,
|
||||
adminSiteId: number,
|
||||
siteCode?: string,
|
||||
): Promise<number> {
|
||||
await resetSiteSettlement(siteCode);
|
||||
const { start, end } = periodWindowIso();
|
||||
const open = await admin.post('/api/v1/admin/settlement-periods', {
|
||||
data: {
|
||||
admin_site_id: adminSiteId,
|
||||
period_start: start,
|
||||
period_end: end,
|
||||
},
|
||||
});
|
||||
expect(open.ok(), `open period status=${open.status()}`).toBeTruthy();
|
||||
const openBody = (await open.json()) as any;
|
||||
expect(openBody.code).toBe(0);
|
||||
return Number(openBody.data.id);
|
||||
}
|
||||
|
||||
export async function mintSsoJwt(sitePlayerId?: string): Promise<{ jwt: string; site_player_id: string }> {
|
||||
const data = await e2e<any>('POST', '/sso/mint-jwt', {
|
||||
site_player_id: sitePlayerId ?? `e2e-sso-${Date.now()}`,
|
||||
|
||||
@@ -27,12 +27,15 @@ export async function runWalletDrawSettlement(params: {
|
||||
playerToken: string;
|
||||
drawNo: string;
|
||||
betNumber?: string;
|
||||
/** 公布头奖号码;默认与 betNumber 相同(必中)。传不同值可测玩家输单。 */
|
||||
publishWinNumber?: string;
|
||||
betAmount?: number;
|
||||
balanceBefore?: number;
|
||||
expectWalletIncrease?: boolean;
|
||||
}): Promise<DrawSettlementResult> {
|
||||
const drawNo = params.drawNo;
|
||||
const betNumber = params.betNumber ?? '1234';
|
||||
const publishWinNumber = params.publishWinNumber ?? betNumber;
|
||||
const betAmount = params.betAmount ?? 10_000;
|
||||
|
||||
const player = await pwPlayerCtx(params.playerToken);
|
||||
@@ -63,7 +66,7 @@ export async function runWalletDrawSettlement(params: {
|
||||
const drawId = await resolveDrawId(admin, drawNo);
|
||||
|
||||
const store = await admin.post(`/api/v1/admin/draws/${drawId}/result-batches`, {
|
||||
data: { items: buildAllResultItems(betNumber) },
|
||||
data: { items: buildAllResultItems(publishWinNumber) },
|
||||
});
|
||||
expect(store.ok(), `store batch status=${store.status()}`).toBeTruthy();
|
||||
const storeBody = (await store.json()) as any;
|
||||
|
||||
28
e2e/tests/ui/admin-settlement-center.spec.ts
Normal file
28
e2e/tests/ui/admin-settlement-center.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_URL = process.env.PLAYWRIGHT_ADMIN_URL ?? 'http://localhost:3801';
|
||||
const ADMIN_ACCOUNT = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '12345678';
|
||||
|
||||
test('超管进入结算中心 → 账期管理页可见', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.goto(`${ADMIN_URL}/admin/login`);
|
||||
const account = page.locator('#admin-account');
|
||||
await account.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await account.fill(ADMIN_ACCOUNT);
|
||||
await page.locator('#admin-password').fill(ADMIN_PASSWORD);
|
||||
await page.locator('img[src^="data:image"]').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await page.locator('#admin-captcha').fill('LOTTERY_E2E_BYPASS');
|
||||
await page.getByRole('button', { name: /^登录$|Sign in|submit/i }).click();
|
||||
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 45_000 });
|
||||
|
||||
await page.goto(`${ADMIN_URL}/admin/settlement-center`);
|
||||
await expect(page.getByText(/结算中心|Settlement center/i).first()).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByText(/账期管理|Period/i).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole('button', { name: /开账|Open period/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
49
e2e/tests/ui/front-place-bet.spec.ts
Normal file
49
e2e/tests/ui/front-place-bet.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { fetchOpenDrawNo, tickDraws } from '../api/_helper';
|
||||
|
||||
const FRONT_URL = process.env.PLAYWRIGHT_FRONT_URL ?? 'http://localhost:3800';
|
||||
const USER = process.env.E2E_PLAYER_USERNAME ?? 'demo_player';
|
||||
const PASS = process.env.E2E_PLAYER_PASSWORD ?? '12345678';
|
||||
|
||||
async function loginToHall(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto(`${FRONT_URL}/login`);
|
||||
const userInput = page.locator('#login-user');
|
||||
await userInput.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await userInput.fill(USER);
|
||||
await page.locator('#login-pass').fill(PASS);
|
||||
await page.locator('img[src^="data:image"]').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await page.locator('#login-captcha').fill('LOTTERY_E2E_BYPASS');
|
||||
await page.getByRole('button', { name: /^登录$|Sign in|submit/i }).click();
|
||||
await page.waitForURL(/\/hall/, { timeout: 45_000 });
|
||||
}
|
||||
|
||||
test('玩家端大厅 → 填写号码金额 → 预览并提交下注', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
await fetchOpenDrawNo();
|
||||
await tickDraws();
|
||||
|
||||
await loginToHall(page);
|
||||
|
||||
const grid = page.getByRole('section', { name: /下注表格|Betting/i });
|
||||
await grid.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
|
||||
const numberInput = grid.locator('input[inputmode="text"]').first();
|
||||
await numberInput.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await numberInput.fill('12');
|
||||
|
||||
const amountInput = grid.locator('input[inputmode="decimal"]').first();
|
||||
await amountInput.fill('10');
|
||||
|
||||
const submitBtn = page.getByRole('button', { name: /提交下注|Submit bet/i });
|
||||
await expect(submitBtn).toBeEnabled({ timeout: 60_000 });
|
||||
await submitBtn.click();
|
||||
|
||||
const confirmBtn = page.getByRole('button', { name: /确认提交|Confirm/i });
|
||||
await confirmBtn.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await confirmBtn.click();
|
||||
|
||||
await expect(page.getByText(/下注成功|Bet placed|订单号|Order/i)).toBeVisible({
|
||||
timeout: 45_000,
|
||||
});
|
||||
});
|
||||
198
tests/Feature/SettlementPartialPaymentCreditTest.php
Normal file
198
tests/Feature/SettlementPartialPaymentCreditTest.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Services\AgentSettlement\AgentSettlementBadDebtService;
|
||||
use App\Services\AgentSettlement\SettlementPaymentService;
|
||||
use App\Support\CreditAmountScale;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* @return array{player: Player, billId: int, admin: AdminUser, netAmount: int}
|
||||
*/
|
||||
function partialPaymentCreditFixture(int $netAmount, int $initialUsedCreditMajor = 5): array
|
||||
{
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$agentId = (int) DB::table('agent_nodes')
|
||||
->where('admin_site_id', (int) $site->id)
|
||||
->where('depth', 0)
|
||||
->value('id');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'site_player_id' => 'partial-'.uniqid('', true),
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'partial_'.uniqid('', true),
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
'agent_node_id' => $agentId,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 100_000,
|
||||
'used_credit' => $initialUsedCreditMajor,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => (int) $site->id,
|
||||
'period_start' => now()->subWeek(),
|
||||
'period_end' => now(),
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$unpaid = abs($netAmount);
|
||||
$billId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => $agentId,
|
||||
'net_amount' => $netAmount,
|
||||
'unpaid_amount' => $unpaid,
|
||||
'paid_amount' => 0,
|
||||
'status' => 'confirmed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'partial_pay_'.uniqid('', true),
|
||||
'name' => 'PartialPay',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
return [
|
||||
'player' => $player,
|
||||
'billId' => $billId,
|
||||
'admin' => $admin,
|
||||
'netAmount' => $netAmount,
|
||||
];
|
||||
}
|
||||
|
||||
function settlementBillLedgerRows(Player $player, int $billId, string $reason): \Illuminate\Support\Collection
|
||||
{
|
||||
return DB::table('credit_ledger')
|
||||
->where('owner_type', 'player')
|
||||
->where('owner_id', $player->id)
|
||||
->where('reason', $reason)
|
||||
->where('ref_type', 'settlement_bill')
|
||||
->where('ref_id', $billId)
|
||||
->get();
|
||||
}
|
||||
|
||||
test('partial payments accumulate settlement_confirm ledger and release used credit incrementally', function (): void {
|
||||
$fixture = partialPaymentCreditFixture(600, 10);
|
||||
$player = $fixture['player'];
|
||||
$billId = $fixture['billId'];
|
||||
$adminId = (int) $fixture['admin']->id;
|
||||
$usedBefore = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit');
|
||||
|
||||
$service = app(SettlementPaymentService::class);
|
||||
$service->recordPayment($billId, 200, $adminId);
|
||||
|
||||
$rows = settlementBillLedgerRows($player, $billId, 'settlement_confirm');
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and((int) $rows[0]->amount)->toBe(200);
|
||||
|
||||
$usedAfterFirst = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit');
|
||||
expect($usedAfterFirst)->toBe($usedBefore - CreditAmountScale::minorToMajor(200, 'NPR'));
|
||||
|
||||
$service->recordPayment($billId, 400, $adminId);
|
||||
|
||||
$rows = settlementBillLedgerRows($player, $billId, 'settlement_confirm');
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and((int) $rows[0]->amount)->toBe(600);
|
||||
|
||||
$usedAfterSecond = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit');
|
||||
expect($usedAfterSecond)->toBe($usedBefore - CreditAmountScale::minorToMajor(600, 'NPR'));
|
||||
|
||||
expect((string) DB::table('settlement_bills')->where('id', $billId)->value('status'))->toBe('settled');
|
||||
});
|
||||
|
||||
test('partial payments accumulate settlement_payout ledger for player win bill', function (): void {
|
||||
$fixture = partialPaymentCreditFixture(-800);
|
||||
$player = $fixture['player'];
|
||||
$billId = $fixture['billId'];
|
||||
$adminId = (int) $fixture['admin']->id;
|
||||
$usedBefore = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit');
|
||||
|
||||
$service = app(SettlementPaymentService::class);
|
||||
$service->recordPayment($billId, 300, $adminId);
|
||||
|
||||
$rows = settlementBillLedgerRows($player, $billId, 'settlement_payout');
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and((int) $rows[0]->amount)->toBe(300);
|
||||
|
||||
$service->recordPayment($billId, 500, $adminId);
|
||||
|
||||
$rows = settlementBillLedgerRows($player, $billId, 'settlement_payout');
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and((int) $rows[0]->amount)->toBe(800);
|
||||
|
||||
$usedAfter = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit');
|
||||
expect($usedAfter)->toBe($usedBefore);
|
||||
|
||||
expect((string) DB::table('settlement_bills')->where('id', $billId)->value('status'))->toBe('settled');
|
||||
});
|
||||
|
||||
test('bad debt after partial payment completes cumulative settlement_confirm ledger', function (): void {
|
||||
$fixture = partialPaymentCreditFixture(800, 10);
|
||||
$player = $fixture['player'];
|
||||
$billId = $fixture['billId'];
|
||||
$adminId = (int) $fixture['admin']->id;
|
||||
$usedBefore = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit');
|
||||
|
||||
$service = app(SettlementPaymentService::class);
|
||||
$service->recordPayment($billId, 300, $adminId);
|
||||
|
||||
$usedAfterPartial = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit');
|
||||
expect($usedAfterPartial)->toBe($usedBefore - CreditAmountScale::minorToMajor(300, 'NPR'));
|
||||
expect((string) DB::table('settlement_bills')->where('id', $billId)->value('status'))->toBe('partial_paid');
|
||||
|
||||
app(AgentSettlementBadDebtService::class)->writeOff($billId, 'uncollectible', $adminId);
|
||||
|
||||
$rows = settlementBillLedgerRows($player, $billId, 'settlement_confirm');
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and((int) $rows[0]->amount)->toBe(800);
|
||||
|
||||
$usedAfterBadDebt = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit');
|
||||
expect($usedAfterBadDebt)->toBe($usedBefore - CreditAmountScale::minorToMajor(800, 'NPR'));
|
||||
|
||||
expect((string) DB::table('settlement_bills')->where('id', $billId)->value('status'))->toBe('settled');
|
||||
expect((int) DB::table('settlement_bills')->where('id', $billId)->value('unpaid_amount'))->toBe(0);
|
||||
});
|
||||
|
||||
test('repeated payment after settled bill does not alter credit ledger', function (): void {
|
||||
$fixture = partialPaymentCreditFixture(500, 8);
|
||||
$player = $fixture['player'];
|
||||
$billId = $fixture['billId'];
|
||||
$adminId = (int) $fixture['admin']->id;
|
||||
|
||||
$service = app(SettlementPaymentService::class);
|
||||
$service->recordPayment($billId, 500, $adminId);
|
||||
|
||||
$usedAfterSettled = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit');
|
||||
expect(settlementBillLedgerRows($player, $billId, 'settlement_confirm'))->toHaveCount(1);
|
||||
|
||||
expect(fn () => $service->recordPayment($billId, 1, $adminId))
|
||||
->toThrow(\Illuminate\Validation\ValidationException::class);
|
||||
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))
|
||||
->toBe($usedAfterSettled);
|
||||
expect((int) settlementBillLedgerRows($player, $billId, 'settlement_confirm')[0]->amount)->toBe(500);
|
||||
});
|
||||
Reference in New Issue
Block a user