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

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;