fix: 部分收付累计释额并扩展结算 E2E 与 CI
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

账期收付改为按累计已付同步 credit_ledger,修复多笔 partial_paid 与坏账核销重复释额;新增 API E2E(占成、权限、部分收付/坏账)与 GitHub Actions e2e-api job。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 09:48:01 +08:00
parent 6ec9634704
commit 3f29229499
23 changed files with 1251 additions and 111 deletions

57
.github/workflows/e2e.yml vendored Normal file
View 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

View File

@@ -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'));

View File

@@ -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);
}
}

View File

@@ -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') {

View File

@@ -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

View File

@@ -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/`

View File

@@ -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,
]);
}
}

View File

@@ -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,
},
},
],

View File

@@ -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'])

View File

@@ -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_shellAPI 仅 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=$?

View File

@@ -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 }],

View File

@@ -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跑前要改

View File

@@ -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,

View File

@@ -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,

View 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();
});

View 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();
});

View 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();
});

View 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();
});

View File

@@ -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()}`,

View File

@@ -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;

View 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,
});
});

View 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,
});
});

View 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);
});