feat: enhance agent settlement features and improve data access controls
- Added new section in AGENTS.md detailing learned workspace facts for better understanding of settlement processes. - Updated AgentNodeDestroyController to remove unnecessary checks for admin users. - Enhanced AgentSettlement controllers to assert permissions for finance adjustments and bill operations. - Improved query scopes in AgentSettlement services to ensure proper data access based on admin roles. - Refactored methods in SettlementPartyEnrichment for better bill row enrichment and data handling. - Introduced new methods in AdminAgentSettlementScope for managing agent node visibility and finance adjustments.
This commit is contained in:
13
AGENTS.md
13
AGENTS.md
@@ -33,3 +33,16 @@
|
|||||||
- **禁止**在生产关账路径使用 `DesignDocExample12` 硬编码账单;仅单元/Feature 测试可引用。
|
- **禁止**在生产关账路径使用 `DesignDocExample12` 硬编码账单;仅单元/Feature 测试可引用。
|
||||||
- 非 `testing` 环境关账受 `AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE`(默认 `true`)控制;预发可设为 `false` 门禁。
|
- 非 `testing` 环境关账受 `AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE`(默认 `true`)控制;预发可设为 `false` 门禁。
|
||||||
- 占成账单聚合必须读注单**快照**(`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。
|
- 占成账单聚合必须读注单**快照**(`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。
|
||||||
|
|
||||||
|
## Learned Workspace Facts
|
||||||
|
|
||||||
|
- 期号 `close_time` / `draw_time` 以 UTC 存储与比较;后台展示转浏览器本地时区,创建/编辑表单提交前须转回 UTC。
|
||||||
|
- 下注是否开放由 `DrawHallSnapshotBuilder::isBettingOpen()` / `effectiveHallDisplayStatus()` 实时判定,不只看 `draws.status`。
|
||||||
|
- 后台期号列表展示数据库 `status`;详情 API 另提供 `hall_preview_status` 供与大厅预览态对比。
|
||||||
|
- 绑定经营代理主账号统一绑平台角色 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对 **所有绑定代理主账号** 自动补足 `settlement.agent.manage`(`AgentProfileCapabilityFilter`),实际操作仍受直属边 + 收款方校验。
|
||||||
|
- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage`(`canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。
|
||||||
|
- 结算账单可见范围(绑定代理):**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点` 或 `counterparty=本节点`(不含下级玩家的账单、不含更深层代理链)。**账务流水/账期 pipeline** 的玩家维度同样仅直属玩家。站点财务/超管仍见全站。
|
||||||
|
- 登记收付/确认:绑定代理仅可操作 **收款方**(玩家账单=直属 counterparty;代理账单=按 net_amount 方向的 payee)。上级不能代登下级玩家收付,下级也不能代登向上级的代理账单。
|
||||||
|
- 收付/调账/坏账后端落库 `payment_records`、`settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。
|
||||||
|
- 代理仪表盘/账期列表「输赢」用本级占成(`share_profit`),不可看 `platform_pnl` 全站报表。
|
||||||
|
- 开/关账期仅未绑定代理的站点财务(`canManagePeriods = canOperateBills && boundAgent === null`)。
|
||||||
|
|||||||
@@ -47,10 +47,6 @@ final class AgentNodeDestroyController extends Controller
|
|||||||
return ApiMessage::errorResponse($request, 'admin.agent_node_has_players_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
return ApiMessage::errorResponse($request, 'admin.agent_node_has_players_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DB::table('admin_user_agents')->where('agent_node_id', $agent_node->id)->exists()) {
|
|
||||||
return ApiMessage::errorResponse($request, 'admin.agent_node_has_admin_users_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($service->hasBlockingCustomRoles($agent_node)) {
|
if ($service->hasBlockingCustomRoles($agent_node)) {
|
||||||
return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Support\AdminAgentSettlementScope;
|
|||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
final class AgentSettlementAdjustmentIndexController extends Controller
|
final class AgentSettlementAdjustmentIndexController extends Controller
|
||||||
@@ -55,6 +56,19 @@ final class AgentSettlementAdjustmentIndexController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actorId = AdminAgentSettlementScope::boundAgentNodeId($admin);
|
||||||
|
if ($actorId !== null) {
|
||||||
|
$query->where(function (Builder $outer) use ($admin): void {
|
||||||
|
$outer->whereNull('sa.original_bill_id')
|
||||||
|
->orWhereExists(function (Builder $exists) use ($admin): void {
|
||||||
|
$exists->selectRaw('1')
|
||||||
|
->from('settlement_bills as sb')
|
||||||
|
->whereColumn('sb.id', 'sa.original_bill_id');
|
||||||
|
AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($exists, $admin, 'sb');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::success([
|
||||||
'items' => $query->limit(200)->get(),
|
'items' => $query->limit(200)->get(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ final class AgentSettlementBillAdjustmentController extends Controller
|
|||||||
$admin = $request->lotteryAdmin();
|
$admin = $request->lotteryAdmin();
|
||||||
abort_if($admin === null, 401);
|
abort_if($admin === null, 401);
|
||||||
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
||||||
|
AdminAgentSettlementScope::assertCanPerformFinanceAdjustments($admin);
|
||||||
|
|
||||||
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||||
abort_if($before === null, 404);
|
abort_if($before === null, 404);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ final class AgentSettlementBillBadDebtWriteOffController extends Controller
|
|||||||
$admin = $request->lotteryAdmin();
|
$admin = $request->lotteryAdmin();
|
||||||
abort_if($admin === null, 401);
|
abort_if($admin === null, 401);
|
||||||
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
||||||
|
AdminAgentSettlementScope::assertCanPerformFinanceAdjustments($admin);
|
||||||
|
|
||||||
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||||
abort_if($before === null, 404);
|
abort_if($before === null, 404);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class AgentSettlementBillConfirmController extends Controller
|
|||||||
$admin = $request->lotteryAdmin();
|
$admin = $request->lotteryAdmin();
|
||||||
abort_if($admin === null, 401);
|
abort_if($admin === null, 401);
|
||||||
|
|
||||||
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
AdminAgentSettlementScope::assertCanOperateBill($admin, $settlement_bill);
|
||||||
|
|
||||||
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||||
abort_if($bill === null, 404);
|
abort_if($bill === null, 404);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ final class AgentSettlementBillIndexController extends Controller
|
|||||||
$items = collect($paginator->items());
|
$items = collect($paginator->items());
|
||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::success([
|
||||||
'items' => $this->enrichBillRows($items),
|
'items' => $this->partyEnrichment->enrichBillRows($items),
|
||||||
'total' => $paginator->total(),
|
'total' => $paginator->total(),
|
||||||
'page' => $paginator->currentPage(),
|
'page' => $paginator->currentPage(),
|
||||||
'per_page' => $paginator->perPage(),
|
'per_page' => $paginator->perPage(),
|
||||||
@@ -138,112 +138,4 @@ final class AgentSettlementBillIndexController extends Controller
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, object> $items
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function enrichBillRows(Collection $items): array
|
|
||||||
{
|
|
||||||
if ($items->isEmpty()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$playerIds = [];
|
|
||||||
$agentIds = [];
|
|
||||||
foreach ($items as $row) {
|
|
||||||
if ((string) $row->owner_type === 'player') {
|
|
||||||
$playerIds[] = (int) $row->owner_id;
|
|
||||||
} elseif ((string) $row->owner_type === 'agent') {
|
|
||||||
$agentIds[] = (int) $row->owner_id;
|
|
||||||
}
|
|
||||||
if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) {
|
|
||||||
$agentIds[] = (int) $row->counterparty_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$players = $playerIds !== []
|
|
||||||
? DB::table('players')
|
|
||||||
->whereIn('id', array_unique($playerIds))
|
|
||||||
->select(['id', 'username', 'site_player_id', 'agent_node_id', 'funding_mode', 'auth_source'])
|
|
||||||
->get()
|
|
||||||
->keyBy('id')
|
|
||||||
: collect();
|
|
||||||
|
|
||||||
foreach ($players as $player) {
|
|
||||||
$aid = (int) ($player->agent_node_id ?? 0);
|
|
||||||
if ($aid > 0) {
|
|
||||||
$agentIds[] = $aid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$agents = $this->partyEnrichment->loadAgents($agentIds);
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
foreach ($items as $row) {
|
|
||||||
$item = (array) $row;
|
|
||||||
$ownerType = (string) $row->owner_type;
|
|
||||||
$counterType = (string) $row->counterparty_type;
|
|
||||||
$counterId = (int) $row->counterparty_id;
|
|
||||||
|
|
||||||
$item['owner_label'] = $this->legacyOwnerLabel($ownerType, (int) $row->owner_id, $players, $agents);
|
|
||||||
$item['counterparty_label'] = $this->partyEnrichment->formatCounterpartyLabel($counterType, $counterId, $agents);
|
|
||||||
|
|
||||||
$item['player_username'] = null;
|
|
||||||
$item['player_site_player_id'] = null;
|
|
||||||
$item['player_id_display'] = null;
|
|
||||||
$item['direct_agent_label'] = null;
|
|
||||||
$item['superior_agent_label'] = null;
|
|
||||||
$item['owner_party_label'] = null;
|
|
||||||
|
|
||||||
if ($ownerType === 'player') {
|
|
||||||
$player = $players->get((int) $row->owner_id);
|
|
||||||
$item['player_username'] = $this->partyEnrichment->formatPlayerUsername($player);
|
|
||||||
$item['player_site_player_id'] = $this->partyEnrichment->formatPlayerSiteId($player);
|
|
||||||
$item['player_id_display'] = (int) $row->owner_id;
|
|
||||||
$item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null;
|
|
||||||
$item['owner_auth_source'] = $player !== null ? $player->auth_source : null;
|
|
||||||
|
|
||||||
$directId = $counterType === 'agent' ? $counterId : (int) ($player->agent_node_id ?? 0);
|
|
||||||
$line = $this->partyEnrichment->agentLineLabels($directId > 0 ? $directId : null, $agents);
|
|
||||||
$item['direct_agent_label'] = $line['direct_agent_label'];
|
|
||||||
$item['superior_agent_label'] = $line['parent_agent_label'];
|
|
||||||
} elseif ($ownerType === 'agent') {
|
|
||||||
$ownerAgentId = (int) $row->owner_id;
|
|
||||||
$item['owner_party_label'] = $this->partyEnrichment->formatAgent($agents->get($ownerAgentId), $ownerAgentId);
|
|
||||||
$item['superior_agent_label'] = $counterType === 'platform'
|
|
||||||
? 'platform'
|
|
||||||
: $this->partyEnrichment->formatCounterpartyLabel($counterType, $counterId, $agents);
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[] = $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, object> $players
|
|
||||||
* @param Collection<int, object> $agents
|
|
||||||
*/
|
|
||||||
private function legacyOwnerLabel(
|
|
||||||
string $type,
|
|
||||||
int $id,
|
|
||||||
Collection $players,
|
|
||||||
Collection $agents,
|
|
||||||
): string {
|
|
||||||
if ($type === 'player') {
|
|
||||||
$player = $players->get($id);
|
|
||||||
|
|
||||||
return $player !== null
|
|
||||||
? (string) ($player->username ?: $player->site_player_id ?: "player#{$id}")
|
|
||||||
: "player#{$id}";
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($type === 'agent') {
|
|
||||||
return $this->partyEnrichment->formatAgent($agents->get($id), $id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return "{$type}#{$id}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ final class AgentSettlementBillPaymentController extends Controller
|
|||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$admin = $request->lotteryAdmin();
|
$admin = $request->lotteryAdmin();
|
||||||
abort_if($admin === null, 401);
|
abort_if($admin === null, 401);
|
||||||
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
AdminAgentSettlementScope::assertCanOperateBill($admin, $settlement_bill);
|
||||||
|
|
||||||
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||||
abort_if($before === null, 404);
|
abort_if($before === null, 404);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\AgentSettlement\SettlementBillDownlineShareBuilder;
|
||||||
use App\Services\AgentSettlement\SettlementPartyEnrichment;
|
use App\Services\AgentSettlement\SettlementPartyEnrichment;
|
||||||
use App\Support\AdminAgentSettlementScope;
|
use App\Support\AdminAgentSettlementScope;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
@@ -14,6 +15,7 @@ final class AgentSettlementBillShowController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SettlementPartyEnrichment $partyEnrichment,
|
private readonly SettlementPartyEnrichment $partyEnrichment,
|
||||||
|
private readonly SettlementBillDownlineShareBuilder $downlineShareBuilder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request, int $settlement_bill): JsonResponse
|
public function __invoke(Request $request, int $settlement_bill): JsonResponse
|
||||||
@@ -77,11 +79,12 @@ final class AgentSettlementBillShowController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::success([
|
||||||
'bill' => $bill,
|
'bill' => $this->partyEnrichment->enrichBillRow($bill),
|
||||||
'payments' => $payments,
|
'payments' => $payments,
|
||||||
'rebate_allocations' => $rebateAllocations,
|
'rebate_allocations' => $rebateAllocations,
|
||||||
'adjustments' => $adjustments,
|
'adjustments' => $adjustments,
|
||||||
'tier_edge' => $tierSettlements,
|
'tier_edge' => $tierSettlements,
|
||||||
|
'downline_shares' => $this->downlineShareBuilder->forBill($bill),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Support\AdminAgentSettlementScope;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -51,6 +52,8 @@ final class AgentSettlementPaymentIndexController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($query, $admin, 'sb');
|
||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::success([
|
||||||
'items' => $query->limit(200)->get(),
|
'items' => $query->limit(200)->get(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\AgentSettlement\SettlementPeriodOpenHintsService;
|
||||||
|
use App\Support\AdminAgentSettlementScope;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/settlement-periods/open-hints */
|
||||||
|
final class AgentSettlementPeriodOpenHintsController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(
|
||||||
|
Request $request,
|
||||||
|
SettlementPeriodOpenHintsService $hintsService,
|
||||||
|
): JsonResponse {
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
abort_if($admin === null, 401);
|
||||||
|
|
||||||
|
$siteId = (int) $request->query('admin_site_id', 0);
|
||||||
|
abort_if($siteId <= 0, 422);
|
||||||
|
abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $siteId), 404);
|
||||||
|
|
||||||
|
return ApiResponse::success($hintsService->hints($siteId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\AgentSettlement\AgentSettlementReportQueryService;
|
use App\Services\AgentSettlement\AgentSettlementReportQueryService;
|
||||||
|
use App\Support\AdminAgentScope;
|
||||||
use App\Support\AgentSettlementPeriodWindow;
|
use App\Support\AgentSettlementPeriodWindow;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -31,6 +32,10 @@ final class AgentSettlementReportShowController extends Controller
|
|||||||
$type = (string) $request->query('type', 'summary');
|
$type = (string) $request->query('type', 'summary');
|
||||||
abort_unless(in_array($type, self::TYPES, true), 404);
|
abort_unless(in_array($type, self::TYPES, true), 404);
|
||||||
|
|
||||||
|
if ($type === 'platform_pnl' && AdminAgentScope::primaryAgentNode($admin) !== null) {
|
||||||
|
abort(403, 'agent_cannot_view_platform_pnl');
|
||||||
|
}
|
||||||
|
|
||||||
$periodId = (int) $request->query('settlement_period_id', 0);
|
$periodId = (int) $request->query('settlement_period_id', 0);
|
||||||
$period = $this->resolvePeriod($periodId, $request);
|
$period = $this->resolvePeriod($periodId, $request);
|
||||||
|
|
||||||
|
|||||||
@@ -409,7 +409,11 @@ final class AdminUser extends Authenticatable
|
|||||||
|
|
||||||
$codes = array_keys($merged);
|
$codes = array_keys($merged);
|
||||||
|
|
||||||
return AgentProfileCapabilityFilter::applyToMenuActionCodes($codes, $this->primaryAgentProfile());
|
return AgentProfileCapabilityFilter::applyToMenuActionCodes(
|
||||||
|
$codes,
|
||||||
|
$this->primaryAgentProfile(),
|
||||||
|
$this->primaryAgentNode(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function primaryAgentProfile(): ?AgentProfile
|
private function primaryAgentProfile(): ?AgentProfile
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services\Admin;
|
namespace App\Services\Admin;
|
||||||
|
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
|
use App\Services\AgentSettlement\ShareLedgerScopedProfitAggregator;
|
||||||
use App\Support\AdminScopeContextResolver;
|
use App\Support\AdminScopeContextResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +13,7 @@ final class AdminDashboardAnalyticsBuilder
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AdminReportQueryService $reportQuery,
|
private readonly AdminReportQueryService $reportQuery,
|
||||||
|
private readonly ShareLedgerScopedProfitAggregator $shareProfitAggregator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +55,27 @@ final class AdminDashboardAnalyticsBuilder
|
|||||||
$dateTo = $range['date_to'];
|
$dateTo = $range['date_to'];
|
||||||
|
|
||||||
$trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scope: $scope);
|
$trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scope: $scope);
|
||||||
|
$summary = $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $scope);
|
||||||
|
$dailySeries = $trend['series'];
|
||||||
|
$profitScope = 'house_gross';
|
||||||
|
|
||||||
|
if ($admin->primaryAgentNode() !== null) {
|
||||||
|
$profitScope = 'share_profit';
|
||||||
|
$shareByDate = $this->shareProfitAggregator->shareProfitByBusinessDate($admin, $dateFrom, $dateTo);
|
||||||
|
$summary['approx_house_gross_minor'] = $this->shareProfitAggregator->sumShareProfitForAdmin(
|
||||||
|
$admin,
|
||||||
|
$dateFrom,
|
||||||
|
$dateTo,
|
||||||
|
);
|
||||||
|
$dailySeries = array_map(
|
||||||
|
static function (array $row) use ($shareByDate): array {
|
||||||
|
$row['approx_house_gross_minor'] = $shareByDate[(string) $row['business_date']] ?? 0;
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
},
|
||||||
|
$dailySeries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'period' => $period,
|
'period' => $period,
|
||||||
@@ -60,9 +83,10 @@ final class AdminDashboardAnalyticsBuilder
|
|||||||
'play_code' => $playCode,
|
'play_code' => $playCode,
|
||||||
'date_from' => $dateFrom,
|
'date_from' => $dateFrom,
|
||||||
'date_to' => $dateTo,
|
'date_to' => $dateTo,
|
||||||
|
'profit_scope' => $profitScope,
|
||||||
'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $scope),
|
'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $scope),
|
||||||
'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $scope),
|
'summary' => $summary,
|
||||||
'daily_series' => $trend['series'],
|
'daily_series' => $dailySeries,
|
||||||
'chart_meta' => [
|
'chart_meta' => [
|
||||||
'chart_date_from' => $trend['chart_date_from'],
|
'chart_date_from' => $trend['chart_date_from'],
|
||||||
'chart_date_to' => $trend['chart_date_to'],
|
'chart_date_to' => $trend['chart_date_to'],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\AgentProfile;
|
|||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Support\AdminScopeContext;
|
use App\Support\AdminScopeContext;
|
||||||
use App\Support\AdminScopeContextResolver;
|
use App\Support\AdminScopeContextResolver;
|
||||||
|
use App\Services\AgentSettlement\ShareLedgerScopedProfitAggregator;
|
||||||
use App\Support\AdminAgentSettlementScope;
|
use App\Support\AdminAgentSettlementScope;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -18,6 +19,7 @@ final class AgentDashboardOverviewBuilder
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AdminReportQueryService $reportQuery,
|
private readonly AdminReportQueryService $reportQuery,
|
||||||
|
private readonly ShareLedgerScopedProfitAggregator $shareProfitAggregator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +60,8 @@ final class AgentDashboardOverviewBuilder
|
|||||||
$sevenDayFrom = now()->subDays(6)->toDateString();
|
$sevenDayFrom = now()->subDays(6)->toDateString();
|
||||||
$todayTotals = $this->reportQuery->periodFinanceTotals($today, $today, $scope);
|
$todayTotals = $this->reportQuery->periodFinanceTotals($today, $today, $scope);
|
||||||
$sevenDayTotals = $this->reportQuery->periodFinanceTotals($sevenDayFrom, $today, $scope);
|
$sevenDayTotals = $this->reportQuery->periodFinanceTotals($sevenDayFrom, $today, $scope);
|
||||||
|
$todayShareProfit = $this->shareProfitAggregator->sumShareProfitForAdmin($admin, $today, $today);
|
||||||
|
$sevenDayShareProfit = $this->shareProfitAggregator->sumShareProfitForAdmin($admin, $sevenDayFrom, $today);
|
||||||
$currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scope)
|
$currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scope)
|
||||||
?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scope);
|
?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scope);
|
||||||
$teamPlayerStats = $this->teamPlayerStats($subtreeIds);
|
$teamPlayerStats = $this->teamPlayerStats($subtreeIds);
|
||||||
@@ -87,10 +91,11 @@ final class AgentDashboardOverviewBuilder
|
|||||||
'bet_order_count_today' => $todayActivityStats['order_count'],
|
'bet_order_count_today' => $todayActivityStats['order_count'],
|
||||||
'today_bet_minor' => $todayTotals['total_bet_minor'],
|
'today_bet_minor' => $todayTotals['total_bet_minor'],
|
||||||
'today_payout_minor' => $todayTotals['total_payout_minor'],
|
'today_payout_minor' => $todayTotals['total_payout_minor'],
|
||||||
'today_profit_minor' => $todayTotals['approx_house_gross_minor'],
|
'today_profit_minor' => $todayShareProfit,
|
||||||
'seven_day_bet_minor' => $sevenDayTotals['total_bet_minor'],
|
'seven_day_bet_minor' => $sevenDayTotals['total_bet_minor'],
|
||||||
'seven_day_payout_minor' => $sevenDayTotals['total_payout_minor'],
|
'seven_day_payout_minor' => $sevenDayTotals['total_payout_minor'],
|
||||||
'seven_day_profit_minor' => $sevenDayTotals['approx_house_gross_minor'],
|
'seven_day_profit_minor' => $sevenDayShareProfit,
|
||||||
|
'profit_scope' => 'share_profit',
|
||||||
'currency_code' => $currencyCode,
|
'currency_code' => $currencyCode,
|
||||||
'pending_bill_count' => $pendingBillStats['count'],
|
'pending_bill_count' => $pendingBillStats['count'],
|
||||||
'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'],
|
'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'],
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ final class AgentPeriodAggregator
|
|||||||
$rows = DB::table('share_ledger as sl')
|
$rows = DB::table('share_ledger as sl')
|
||||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||||
->where('p.site_code', $siteCode)
|
->where('p.site_code', $siteCode)
|
||||||
|
->whereNull('sl.settlement_period_id')
|
||||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||||
->select([
|
->select([
|
||||||
'sl.player_id',
|
'sl.player_id',
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ final class AgentSettlementPeriodCloseService
|
|||||||
->from('share_ledger as sl')
|
->from('share_ledger as sl')
|
||||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||||
->where('p.site_code', $siteCode)
|
->where('p.site_code', $siteCode)
|
||||||
|
->whereNull('sl.settlement_period_id')
|
||||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd]);
|
->whereBetween('sl.settled_at', [$periodStart, $periodEnd]);
|
||||||
})
|
})
|
||||||
->update(['settlement_period_id' => $periodId]);
|
->update(['settlement_period_id' => $periodId]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\AgentSettlement;
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
|
use App\Support\AgentSettlementPeriodWindow;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@@ -14,8 +15,10 @@ final class AgentSettlementPeriodOpenService
|
|||||||
public function open(array $data): object
|
public function open(array $data): object
|
||||||
{
|
{
|
||||||
$siteId = (int) $data['admin_site_id'];
|
$siteId = (int) $data['admin_site_id'];
|
||||||
$start = (string) $data['period_start'];
|
[$start, $end] = AgentSettlementPeriodWindow::normalizeInputBounds(
|
||||||
$end = (string) $data['period_end'];
|
(string) $data['period_start'],
|
||||||
|
(string) $data['period_end'],
|
||||||
|
);
|
||||||
|
|
||||||
$existingSameRange = DB::table('settlement_periods')
|
$existingSameRange = DB::table('settlement_periods')
|
||||||
->where('admin_site_id', $siteId)
|
->where('admin_site_id', $siteId)
|
||||||
@@ -43,6 +46,12 @@ final class AgentSettlementPeriodOpenService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->overlapsExistingPeriod($siteId, $start, $end)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'period_start' => ['period_overlaps_existing'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$id = (int) DB::table('settlement_periods')->insertGetId([
|
$id = (int) DB::table('settlement_periods')->insertGetId([
|
||||||
'admin_site_id' => $siteId,
|
'admin_site_id' => $siteId,
|
||||||
'period_start' => $start,
|
'period_start' => $start,
|
||||||
@@ -59,4 +68,13 @@ final class AgentSettlementPeriodOpenService
|
|||||||
|
|
||||||
return $row;
|
return $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function overlapsExistingPeriod(int $siteId, string $start, string $end): bool
|
||||||
|
{
|
||||||
|
return DB::table('settlement_periods')
|
||||||
|
->where('admin_site_id', $siteId)
|
||||||
|
->where('period_start', '<=', $end)
|
||||||
|
->where('period_end', '>=', $start)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace App\Services\AgentSettlement;
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Support\AdminDataScope;
|
use App\Support\AdminAgentSettlementScope;
|
||||||
use App\Support\AgentSettlementPeriodWindow;
|
use App\Support\AgentSettlementPeriodWindow;
|
||||||
use App\Support\PlayerFundingMode;
|
use App\Support\PlayerFundingMode;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -71,17 +71,18 @@ final class AgentSettlementPeriodPipelineService
|
|||||||
->whereBetween('cl.created_at', [$start, $end]);
|
->whereBetween('cl.created_at', [$start, $end]);
|
||||||
|
|
||||||
if ($admin !== null) {
|
if ($admin !== null) {
|
||||||
AdminDataScope::applyToPlayersAlias($creditQuery, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($creditQuery, $admin, 'p');
|
||||||
}
|
}
|
||||||
|
|
||||||
$shareQuery = DB::table('share_ledger as sl')
|
$shareQuery = DB::table('share_ledger as sl')
|
||||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||||
->where('p.site_code', $siteCode)
|
->where('p.site_code', $siteCode)
|
||||||
|
->whereNull('sl.settlement_period_id')
|
||||||
->whereBetween('sl.settled_at', [$start, $end])
|
->whereBetween('sl.settled_at', [$start, $end])
|
||||||
->whereNull('sl.reversal_of_id');
|
->whereNull('sl.reversal_of_id');
|
||||||
|
|
||||||
if ($admin !== null) {
|
if ($admin !== null) {
|
||||||
AdminDataScope::applyToPlayersAlias($shareQuery, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($shareQuery, $admin, 'p');
|
||||||
}
|
}
|
||||||
|
|
||||||
$shareAgg = (clone $shareQuery)
|
$shareAgg = (clone $shareQuery)
|
||||||
|
|||||||
@@ -332,23 +332,17 @@ final class AgentSettlementReportQueryService
|
|||||||
|
|
||||||
private function applyPlayerSubtree(Builder $query, AdminUser $admin, string $alias = 'p'): void
|
private function applyPlayerSubtree(Builder $query, AdminUser $admin, string $alias = 'p'): void
|
||||||
{
|
{
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, $alias);
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, $alias);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyAgentSubtree(Builder $query, AdminUser $admin, string $agentNodeColumn): void
|
private function applyAgentSubtree(Builder $query, AdminUser $admin, string $agentNodeColumn): void
|
||||||
{
|
{
|
||||||
$subtreeIds = AdminAgentSettlementScope::subtreeAgentNodeIds($admin);
|
$actorId = AdminAgentSettlementScope::boundAgentNodeId($admin);
|
||||||
if ($subtreeIds === null) {
|
if ($actorId === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($subtreeIds === []) {
|
$query->where($agentNodeColumn, $actorId);
|
||||||
$query->whereRaw('0 = 1');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->whereIn($agentNodeColumn, $subtreeIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function siteCodeForAdmin(AdminUser $admin, int $periodId): string
|
private function siteCodeForAdmin(AdminUser $admin, int $periodId): string
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
|
use App\Models\AgentNode;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/** 代理账单:汇总下级代理在本期保留的占成。 */
|
||||||
|
final class SettlementBillDownlineShareBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SettlementPartyEnrichment $partyEnrichment,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* total: int,
|
||||||
|
* items: list<array{owner_id: int, owner_label: string, share_profit: int}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function forBill(object $bill): array
|
||||||
|
{
|
||||||
|
if ((string) $bill->bill_type !== 'agent' || (string) $bill->owner_type !== 'agent') {
|
||||||
|
return ['total' => 0, 'items' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ownerId = (int) $bill->owner_id;
|
||||||
|
$periodId = (int) $bill->settlement_period_id;
|
||||||
|
if ($ownerId <= 0 || $periodId <= 0) {
|
||||||
|
return ['total' => 0, 'items' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$owner = AgentNode::query()->find($ownerId);
|
||||||
|
if ($owner === null) {
|
||||||
|
return ['total' => 0, 'items' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$descendantIds = AgentNode::query()
|
||||||
|
->where('admin_site_id', (int) $owner->admin_site_id)
|
||||||
|
->where('id', '!=', $ownerId)
|
||||||
|
->where('path', 'like', $owner->path.'%')
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn ($id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($descendantIds === []) {
|
||||||
|
return ['total' => 0, 'items' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = DB::table('settlement_bills')
|
||||||
|
->where('settlement_period_id', $periodId)
|
||||||
|
->where('bill_type', 'agent')
|
||||||
|
->where('owner_type', 'agent')
|
||||||
|
->whereIn('owner_id', $descendantIds)
|
||||||
|
->orderBy('owner_id')
|
||||||
|
->get(['owner_id', 'meta_json']);
|
||||||
|
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
return ['total' => 0, 'items' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$agentIds = $rows->pluck('owner_id')->map(static fn ($id): int => (int) $id)->all();
|
||||||
|
$agents = $this->partyEnrichment->loadAgents($agentIds);
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$total = 0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$shareProfit = $this->shareProfitFromMeta($row->meta_json ?? null);
|
||||||
|
if ($shareProfit === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agentId = (int) $row->owner_id;
|
||||||
|
$items[] = [
|
||||||
|
'owner_id' => $agentId,
|
||||||
|
'owner_label' => $this->partyEnrichment->formatAgent($agents->get($agentId), $agentId),
|
||||||
|
'share_profit' => $shareProfit,
|
||||||
|
];
|
||||||
|
$total += $shareProfit;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($items, static fn (array $a, array $b): int => $b['share_profit'] <=> $a['share_profit']
|
||||||
|
?: $a['owner_label'] <=> $b['owner_label']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shareProfitFromMeta(mixed $metaJson): int
|
||||||
|
{
|
||||||
|
if ($metaJson === null || $metaJson === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = is_string($metaJson) ? json_decode($metaJson, true) : $metaJson;
|
||||||
|
|
||||||
|
return is_array($decoded) ? (int) ($decoded['share_profit'] ?? 0) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Services\AgentSettlement;
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Support\AdminDataScope;
|
|
||||||
use App\Support\AdminAgentSettlementScope;
|
use App\Support\AdminAgentSettlementScope;
|
||||||
use App\Support\AgentSettlementPeriodWindow;
|
use App\Support\AgentSettlementPeriodWindow;
|
||||||
use App\Support\CurrencyFormatter;
|
use App\Support\CurrencyFormatter;
|
||||||
@@ -246,7 +245,7 @@ final class SettlementCenterLedgerService
|
|||||||
->whereNull('sl.reversal_of_id')
|
->whereNull('sl.reversal_of_id')
|
||||||
->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at");
|
->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at");
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
$this->applyLedgerPlayerFilters($query, 'p', $filters);
|
$this->applyLedgerPlayerFilters($query, 'p', $filters);
|
||||||
|
|
||||||
if ($range !== null) {
|
if ($range !== null) {
|
||||||
@@ -276,7 +275,7 @@ final class SettlementCenterLedgerService
|
|||||||
->where('p.funding_mode', PlayerFundingMode::CREDIT)
|
->where('p.funding_mode', PlayerFundingMode::CREDIT)
|
||||||
->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at");
|
->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at");
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
$this->applyLedgerPlayerFilters($query, 'p', $filters);
|
$this->applyLedgerPlayerFilters($query, 'p', $filters);
|
||||||
|
|
||||||
if ($range !== null) {
|
if ($range !== null) {
|
||||||
@@ -408,7 +407,7 @@ final class SettlementCenterLedgerService
|
|||||||
$outer->whereNull('p.id')
|
$outer->whereNull('p.id')
|
||||||
->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void {
|
->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void {
|
||||||
$scoped->where('p.site_code', $siteCode);
|
$scoped->where('p.site_code', $siteCode);
|
||||||
AdminDataScope::applyToPlayersAlias($scoped, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p');
|
||||||
$this->applyLedgerPlayerFilters($scoped, 'p', $filters);
|
$this->applyLedgerPlayerFilters($scoped, 'p', $filters);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -767,7 +766,7 @@ final class SettlementCenterLedgerService
|
|||||||
'sla.name as share_agent_name',
|
'sla.name as share_agent_name',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
|
|
||||||
return $query->get()->all();
|
return $query->get()->all();
|
||||||
}
|
}
|
||||||
@@ -840,7 +839,7 @@ final class SettlementCenterLedgerService
|
|||||||
$query->where('sb.settlement_period_id', $periodId);
|
$query->where('sb.settlement_period_id', $periodId);
|
||||||
}
|
}
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
|
|
||||||
$map = [];
|
$map = [];
|
||||||
foreach ($query->limit(500)->get() as $bill) {
|
foreach ($query->limit(500)->get() as $bill) {
|
||||||
@@ -907,7 +906,7 @@ final class SettlementCenterLedgerService
|
|||||||
])
|
])
|
||||||
->orderByDesc('cl.id');
|
->orderByDesc('cl.id');
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
|
|
||||||
if ($playerId !== null && $playerId > 0) {
|
if ($playerId !== null && $playerId > 0) {
|
||||||
$query->where('p.id', $playerId);
|
$query->where('p.id', $playerId);
|
||||||
@@ -969,7 +968,7 @@ final class SettlementCenterLedgerService
|
|||||||
])
|
])
|
||||||
->orderByDesc('cl.id');
|
->orderByDesc('cl.id');
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
$this->applyLedgerPlayerFilters($query, 'p', $filters);
|
$this->applyLedgerPlayerFilters($query, 'p', $filters);
|
||||||
|
|
||||||
if ($range !== null) {
|
if ($range !== null) {
|
||||||
@@ -1023,7 +1022,7 @@ final class SettlementCenterLedgerService
|
|||||||
'pa.name as parent_agent_name',
|
'pa.name as parent_agent_name',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
|
|
||||||
return $query->get()->all();
|
return $query->get()->all();
|
||||||
}
|
}
|
||||||
@@ -1082,7 +1081,7 @@ final class SettlementCenterLedgerService
|
|||||||
$query->where('p.id', $playerId);
|
$query->where('p.id', $playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
|
|
||||||
$siteIds = $admin->accessibleAdminSiteIds();
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
if ($siteIds !== null) {
|
if ($siteIds !== null) {
|
||||||
@@ -1212,7 +1211,7 @@ final class SettlementCenterLedgerService
|
|||||||
$query->where('p.id', $playerId);
|
$query->where('p.id', $playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
|
|
||||||
$siteIds = $admin->accessibleAdminSiteIds();
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
if ($siteIds !== null) {
|
if ($siteIds !== null) {
|
||||||
@@ -1272,7 +1271,7 @@ final class SettlementCenterLedgerService
|
|||||||
'pa.name as parent_agent_name',
|
'pa.name as parent_agent_name',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
$this->applyLedgerSiteScope($query, $admin, 'sp');
|
$this->applyLedgerSiteScope($query, $admin, 'sp');
|
||||||
|
|
||||||
return $query->get()->all();
|
return $query->get()->all();
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ final class SettlementPartyEnrichment
|
|||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 结算展示:仅代理名称;编号为内部标识,无名称时才回退 code。 */
|
||||||
public function formatAgent(?object $agent, int $fallbackId): string
|
public function formatAgent(?object $agent, int $fallbackId): string
|
||||||
{
|
{
|
||||||
if ($agent === null) {
|
if ($agent === null) {
|
||||||
@@ -107,13 +108,13 @@ final class SettlementPartyEnrichment
|
|||||||
}
|
}
|
||||||
|
|
||||||
$name = trim((string) ($agent->name ?? ''));
|
$name = trim((string) ($agent->name ?? ''));
|
||||||
$code = trim((string) ($agent->code ?? ''));
|
if ($name !== '') {
|
||||||
|
return $name;
|
||||||
if ($name !== '' && $code !== '') {
|
|
||||||
return "{$name} ({$code})";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $name !== '' ? $name : ($code !== '' ? $code : "agent#{$fallbackId}");
|
$code = trim((string) ($agent->code ?? ''));
|
||||||
|
|
||||||
|
return $code !== '' ? $code : "agent#{$fallbackId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public function formatPlayerUsername(?object $player): ?string
|
public function formatPlayerUsername(?object $player): ?string
|
||||||
@@ -150,4 +151,129 @@ final class SettlementPartyEnrichment
|
|||||||
|
|
||||||
return "{$type}#{$id}";
|
return "{$type}#{$id}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<int, object> $rows
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function enrichBillRows(iterable $rows): array
|
||||||
|
{
|
||||||
|
$items = collect($rows);
|
||||||
|
if ($items->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$playerIds = [];
|
||||||
|
$agentIds = [];
|
||||||
|
foreach ($items as $row) {
|
||||||
|
if ((string) $row->owner_type === 'player') {
|
||||||
|
$playerIds[] = (int) $row->owner_id;
|
||||||
|
} elseif ((string) $row->owner_type === 'agent') {
|
||||||
|
$agentIds[] = (int) $row->owner_id;
|
||||||
|
}
|
||||||
|
if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) {
|
||||||
|
$agentIds[] = (int) $row->counterparty_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$players = $playerIds !== []
|
||||||
|
? DB::table('players')
|
||||||
|
->whereIn('id', array_unique($playerIds))
|
||||||
|
->select(['id', 'username', 'site_player_id', 'agent_node_id', 'funding_mode', 'auth_source'])
|
||||||
|
->get()
|
||||||
|
->keyBy('id')
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
foreach ($players as $player) {
|
||||||
|
$aid = (int) ($player->agent_node_id ?? 0);
|
||||||
|
if ($aid > 0) {
|
||||||
|
$agentIds[] = $aid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$agents = $this->loadAgents($agentIds);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($items as $row) {
|
||||||
|
$out[] = $this->enrichBillRowWithLookups($row, $players, $agents);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function enrichBillRow(object $row): array
|
||||||
|
{
|
||||||
|
return $this->enrichBillRows([$row])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, object> $players
|
||||||
|
* @param Collection<int, object> $agents
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function enrichBillRowWithLookups(object $row, Collection $players, Collection $agents): array
|
||||||
|
{
|
||||||
|
$item = (array) $row;
|
||||||
|
$ownerType = (string) $row->owner_type;
|
||||||
|
$counterType = (string) $row->counterparty_type;
|
||||||
|
$counterId = (int) $row->counterparty_id;
|
||||||
|
|
||||||
|
$item['owner_label'] = $this->legacyOwnerLabel($ownerType, (int) $row->owner_id, $players, $agents);
|
||||||
|
$item['counterparty_label'] = $this->formatCounterpartyLabel($counterType, $counterId, $agents);
|
||||||
|
|
||||||
|
$item['player_username'] = null;
|
||||||
|
$item['player_site_player_id'] = null;
|
||||||
|
$item['player_id_display'] = null;
|
||||||
|
$item['direct_agent_label'] = null;
|
||||||
|
$item['superior_agent_label'] = null;
|
||||||
|
$item['owner_party_label'] = null;
|
||||||
|
|
||||||
|
if ($ownerType === 'player') {
|
||||||
|
$player = $players->get((int) $row->owner_id);
|
||||||
|
$item['player_username'] = $this->formatPlayerUsername($player);
|
||||||
|
$item['player_site_player_id'] = $this->formatPlayerSiteId($player);
|
||||||
|
$item['player_id_display'] = (int) $row->owner_id;
|
||||||
|
$item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null;
|
||||||
|
$item['owner_auth_source'] = $player !== null ? $player->auth_source : null;
|
||||||
|
|
||||||
|
$directId = $counterType === 'agent' ? $counterId : (int) ($player->agent_node_id ?? 0);
|
||||||
|
$line = $this->agentLineLabels($directId > 0 ? $directId : null, $agents);
|
||||||
|
$item['direct_agent_label'] = $line['direct_agent_label'];
|
||||||
|
$item['superior_agent_label'] = $line['parent_agent_label'];
|
||||||
|
} elseif ($ownerType === 'agent') {
|
||||||
|
$ownerAgentId = (int) $row->owner_id;
|
||||||
|
$item['owner_party_label'] = $this->formatAgent($agents->get($ownerAgentId), $ownerAgentId);
|
||||||
|
$item['superior_agent_label'] = $counterType === 'platform'
|
||||||
|
? 'platform'
|
||||||
|
: $this->formatCounterpartyLabel($counterType, $counterId, $agents);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, object> $players
|
||||||
|
* @param Collection<int, object> $agents
|
||||||
|
*/
|
||||||
|
private function legacyOwnerLabel(
|
||||||
|
string $type,
|
||||||
|
int $id,
|
||||||
|
Collection $players,
|
||||||
|
Collection $agents,
|
||||||
|
): string {
|
||||||
|
if ($type === 'player') {
|
||||||
|
$player = $players->get($id);
|
||||||
|
|
||||||
|
return $player !== null
|
||||||
|
? (string) ($player->username ?: $player->site_player_id ?: "player#{$id}")
|
||||||
|
: "player#{$id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'agent') {
|
||||||
|
return $this->formatAgent($agents->get($id), $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$type}#{$id}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/** 开账弹窗:建议账期与日历标记(已有账期 / 待入账 / 未结清)。 */
|
||||||
|
final class SettlementPeriodOpenHintsService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* suggested_start: string,
|
||||||
|
* suggested_end: string,
|
||||||
|
* occupied_period_dates: list<string>,
|
||||||
|
* pending_activity_dates: list<string>,
|
||||||
|
* unpaid_bill_dates: list<string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function hints(int $adminSiteId): array
|
||||||
|
{
|
||||||
|
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
|
||||||
|
if ($siteCode === '') {
|
||||||
|
return $this->emptyHints();
|
||||||
|
}
|
||||||
|
|
||||||
|
$periodRows = DB::table('settlement_periods')
|
||||||
|
->where('admin_site_id', $adminSiteId)
|
||||||
|
->orderBy('period_start')
|
||||||
|
->get(['period_start', 'period_end', 'status']);
|
||||||
|
|
||||||
|
$occupiedPeriodDates = [];
|
||||||
|
foreach ($periodRows as $row) {
|
||||||
|
foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) {
|
||||||
|
$occupiedPeriodDates[$day] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastPeriod = DB::table('settlement_periods')
|
||||||
|
->where('admin_site_id', $adminSiteId)
|
||||||
|
->whereIn('status', ['closed', 'completed'])
|
||||||
|
->orderByDesc('period_end')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$pendingActivityDates = DB::table('share_ledger as sl')
|
||||||
|
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||||
|
->where('p.site_code', $siteCode)
|
||||||
|
->whereNull('sl.settlement_period_id')
|
||||||
|
->whereNull('sl.reversal_of_id')
|
||||||
|
->selectRaw('DATE(sl.settled_at) as activity_day')
|
||||||
|
->groupBy('activity_day')
|
||||||
|
->orderBy('activity_day')
|
||||||
|
->pluck('activity_day')
|
||||||
|
->map(static fn ($day): string => (string) $day)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$unpaidPeriodRows = DB::table('settlement_periods as sp')
|
||||||
|
->where('sp.admin_site_id', $adminSiteId)
|
||||||
|
->whereIn('sp.status', ['closed', 'completed'])
|
||||||
|
->whereExists(function ($query): void {
|
||||||
|
$query->selectRaw('1')
|
||||||
|
->from('settlement_bills as sb')
|
||||||
|
->whereColumn('sb.settlement_period_id', 'sp.id')
|
||||||
|
->where('sb.unpaid_amount', '>', 0)
|
||||||
|
->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']);
|
||||||
|
})
|
||||||
|
->orderBy('sp.period_start')
|
||||||
|
->get(['sp.period_start', 'sp.period_end']);
|
||||||
|
|
||||||
|
$unpaidBillDates = [];
|
||||||
|
foreach ($unpaidPeriodRows as $row) {
|
||||||
|
foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) {
|
||||||
|
$unpaidBillDates[$day] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$suggested = $this->suggestRange($lastPeriod, $pendingActivityDates, $occupiedPeriodDates);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'suggested_start' => $suggested['start'],
|
||||||
|
'suggested_end' => $suggested['end'],
|
||||||
|
'occupied_period_dates' => array_keys($occupiedPeriodDates),
|
||||||
|
'pending_activity_dates' => $pendingActivityDates,
|
||||||
|
'unpaid_bill_dates' => array_keys($unpaidBillDates),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $pendingActivityDates UTC `Y-m-d`
|
||||||
|
* @param array<string, true> $occupiedPeriodDates
|
||||||
|
* @return array{start: string, end: string}
|
||||||
|
*/
|
||||||
|
private function suggestRange(?object $lastPeriod, array $pendingActivityDates, array $occupiedPeriodDates): array
|
||||||
|
{
|
||||||
|
$lastEndDay = $lastPeriod !== null
|
||||||
|
? Carbon::parse((string) $lastPeriod->period_end)->utc()->startOfDay()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$freePending = array_values(array_filter(
|
||||||
|
$pendingActivityDates,
|
||||||
|
static fn (string $day): bool => ! isset($occupiedPeriodDates[$day]),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($freePending !== []) {
|
||||||
|
$minDay = Carbon::parse($freePending[0])->utc()->startOfDay();
|
||||||
|
$maxDay = Carbon::parse($freePending[array_key_last($freePending)])->utc()->startOfDay();
|
||||||
|
$startDay = $lastEndDay !== null
|
||||||
|
? ($lastEndDay->copy()->addDay()->lessThanOrEqualTo($minDay) ? $lastEndDay->copy()->addDay() : $minDay)
|
||||||
|
: $minDay;
|
||||||
|
|
||||||
|
$candidate = [
|
||||||
|
'start' => $startDay->format('Y-m-d'),
|
||||||
|
'end' => $maxDay->format('Y-m-d'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->withoutOccupiedOverlap($candidate, $occupiedPeriodDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lastEndDay !== null) {
|
||||||
|
$startDay = $lastEndDay->copy()->addDay();
|
||||||
|
$endDay = Carbon::now('UTC')->subDay()->startOfDay();
|
||||||
|
if ($endDay->lessThan($startDay)) {
|
||||||
|
return ['start' => '', 'end' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->withoutOccupiedOverlap([
|
||||||
|
'start' => $startDay->format('Y-m-d'),
|
||||||
|
'end' => $endDay->format('Y-m-d'),
|
||||||
|
], $occupiedPeriodDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['start' => '', 'end' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{start: string, end: string} $candidate
|
||||||
|
* @param array<string, true> $occupiedPeriodDates
|
||||||
|
* @return array{start: string, end: string}
|
||||||
|
*/
|
||||||
|
private function withoutOccupiedOverlap(array $candidate, array $occupiedPeriodDates): array
|
||||||
|
{
|
||||||
|
if ($candidate['start'] === '' || $candidate['end'] === '') {
|
||||||
|
return ['start' => '', 'end' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->rangeOverlapsOccupied($candidate['start'], $candidate['end'], $occupiedPeriodDates)) {
|
||||||
|
return ['start' => '', 'end' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, true> $occupiedPeriodDates
|
||||||
|
*/
|
||||||
|
private function rangeOverlapsOccupied(string $startYmd, string $endYmd, array $occupiedPeriodDates): bool
|
||||||
|
{
|
||||||
|
$cursor = Carbon::parse($startYmd)->utc()->startOfDay();
|
||||||
|
$end = Carbon::parse($endYmd)->utc()->startOfDay();
|
||||||
|
|
||||||
|
while ($cursor->lessThanOrEqualTo($end)) {
|
||||||
|
if (isset($occupiedPeriodDates[$cursor->format('Y-m-d')])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$cursor->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> 站点本地日历 `Y-m-d`(东八区,与后台开账日期选择一致) */
|
||||||
|
private function expandPeriodToUtcDays(string $periodStart, string $periodEnd): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
$tz = 'Asia/Shanghai';
|
||||||
|
$cursor = Carbon::parse($periodStart)->timezone($tz)->startOfDay();
|
||||||
|
$end = Carbon::parse($periodEnd)->timezone($tz)->startOfDay();
|
||||||
|
|
||||||
|
while ($cursor->lessThanOrEqualTo($end)) {
|
||||||
|
$dates[] = $cursor->format('Y-m-d');
|
||||||
|
$cursor->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{suggested_start: string, suggested_end: string, occupied_period_dates: list<string>, pending_activity_dates: list<string>, unpaid_bill_dates: list<string>} */
|
||||||
|
private function emptyHints(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'suggested_start' => '',
|
||||||
|
'suggested_end' => '',
|
||||||
|
'occupied_period_dates' => [],
|
||||||
|
'pending_activity_dates' => [],
|
||||||
|
'unpaid_bill_dates' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ namespace App\Services\AgentSettlement;
|
|||||||
|
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Support\AdminAgentScope;
|
use App\Support\AdminAgentScope;
|
||||||
|
use App\Support\AdminDataScope;
|
||||||
use Illuminate\Database\Query\Builder;
|
use Illuminate\Database\Query\Builder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/** 按登录视角(平台 / 绑定代理)汇总占成流水 allocations 中的本级输赢。 */
|
/** 按登录视角(平台 / 绑定代理)汇总占成流水 allocations 中的本级输赢。 */
|
||||||
final class ShareLedgerScopedProfitAggregator
|
final class ShareLedgerScopedProfitAggregator
|
||||||
@@ -54,6 +56,56 @@ final class ShareLedgerScopedProfitAggregator
|
|||||||
return (int) ((clone $shareQuery)->sum('sl.game_win_loss') ?? 0);
|
return (int) ((clone $shareQuery)->sum('sl.game_win_loss') ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 绑定代理账号:区间内本级占成收益合计(minor)。 */
|
||||||
|
public function sumShareProfitForAdmin(AdminUser $admin, string $dateFrom, string $dateTo): int
|
||||||
|
{
|
||||||
|
$viewer = $this->resolveViewer($admin);
|
||||||
|
if ($viewer['scope'] !== 'agent') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sumForShareQuery(
|
||||||
|
$this->shareLedgerBaseQuery($admin, $dateFrom, $dateTo),
|
||||||
|
$viewer['key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int> business_date (Y-m-d) => share_profit_minor
|
||||||
|
*/
|
||||||
|
public function shareProfitByBusinessDate(AdminUser $admin, string $dateFrom, string $dateTo): array
|
||||||
|
{
|
||||||
|
$viewer = $this->resolveViewer($admin);
|
||||||
|
if ($viewer['scope'] !== 'agent') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->shareLedgerBaseQuery($admin, $dateFrom, $dateTo)
|
||||||
|
->selectRaw('DATE(sl.settled_at) as business_date')
|
||||||
|
->addSelect(['sl.allocations_json', 'sl.game_win_loss', 'sl.basic_rebate', 'sl.share_snapshot'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byDate = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$date = (string) $row->business_date;
|
||||||
|
$byDate[$date] = ($byDate[$date] ?? 0) + $this->profitFromRow($row, $viewer['key']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shareLedgerBaseQuery(AdminUser $admin, string $dateFrom, string $dateTo): Builder
|
||||||
|
{
|
||||||
|
$query = DB::table('share_ledger as sl')
|
||||||
|
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||||
|
->whereNull('sl.reversal_of_id')
|
||||||
|
->whereDate('sl.settled_at', '>=', $dateFrom)
|
||||||
|
->whereDate('sl.settled_at', '<=', $dateTo);
|
||||||
|
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
private function profitFromRow(object $row, string $profitKey): int
|
private function profitFromRow(object $row, string $profitKey): int
|
||||||
{
|
{
|
||||||
$allocations = $this->decodeJsonObject($row->allocations_json ?? null);
|
$allocations = $this->decodeJsonObject($row->allocations_json ?? null);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use App\Models\AdminUser;
|
|||||||
use App\Models\AgentNode;
|
use App\Models\AgentNode;
|
||||||
use Illuminate\Database\Query\Builder;
|
use Illuminate\Database\Query\Builder;
|
||||||
|
|
||||||
/** 代理账单按管理员可访问站点 + 代理子树过滤。 */
|
/** 结算中心账单:站点范围 + 绑定代理仅见直属边(玩家↔直属代理、代理↔直接上下级)。 */
|
||||||
final class AdminAgentSettlementScope
|
final class AdminAgentSettlementScope
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +32,17 @@ final class AdminAgentSettlementScope
|
|||||||
return $ids;
|
return $ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function boundAgentNodeId(AdminUser $admin): ?int
|
||||||
|
{
|
||||||
|
if ($admin->isSuperAdmin()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = AdminAgentScope::primaryAgentNode($admin);
|
||||||
|
|
||||||
|
return $actor !== null ? (int) $actor->id : null;
|
||||||
|
}
|
||||||
|
|
||||||
public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void
|
public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void
|
||||||
{
|
{
|
||||||
$siteIds = $admin->accessibleAdminSiteIds();
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
@@ -52,7 +63,7 @@ final class AdminAgentSettlementScope
|
|||||||
{
|
{
|
||||||
$siteIds = $admin->accessibleAdminSiteIds();
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
if ($siteIds === null) {
|
if ($siteIds === null) {
|
||||||
self::applySubtreeToBillsQuery($query, $admin, $billsAlias);
|
self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -70,35 +81,72 @@ final class AdminAgentSettlementScope
|
|||||||
->whereIn('settlement_periods.admin_site_id', $siteIds);
|
->whereIn('settlement_periods.admin_site_id', $siteIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
self::applySubtreeToBillsQuery($query, $admin, $billsAlias);
|
self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 绑定代理仅见本子树玩家账单 + owner 为本子树节点的代理账单。 */
|
/** @deprecated 使用 {@see applyDirectEdgeScopeToBillsQuery} */
|
||||||
public static function applySubtreeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
|
public static function applySubtreeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
|
||||||
{
|
{
|
||||||
$subtreeIds = self::subtreeAgentNodeIds($admin);
|
self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
|
||||||
if ($subtreeIds === null) {
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定代理:
|
||||||
|
* - 玩家账单:仅直属玩家(players.agent_node_id = 本节点)
|
||||||
|
* - 代理账单:owner=本节点(向上)或 counterparty=本节点(下级向我结)
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 结算中心玩家维度:绑定代理仅见直属玩家;站点财务/超管见全站(由调用方再限 site_code)。
|
||||||
|
*/
|
||||||
|
public static function applyDirectPlayersToAlias(Builder $query, AdminUser $admin, string $alias = 'p'): void
|
||||||
|
{
|
||||||
|
if ($admin->isSuperAdmin() || self::canManageSitePeriods($admin)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($subtreeIds === []) {
|
$actorId = self::boundAgentNodeId($admin);
|
||||||
|
if ($actorId === null) {
|
||||||
$query->whereRaw('0 = 1');
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->where(function (Builder $outer) use ($billsAlias, $subtreeIds): void {
|
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
|
||||||
$outer->where(function (Builder $player) use ($billsAlias, $subtreeIds): void {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->where($alias.'.agent_node_id', $actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function applyDirectEdgeScopeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
|
||||||
|
{
|
||||||
|
$actorId = self::boundAgentNodeId($admin);
|
||||||
|
if ($actorId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->where(function (Builder $outer) use ($billsAlias, $actorId): void {
|
||||||
|
$outer->where(function (Builder $player) use ($billsAlias, $actorId): void {
|
||||||
$player->where($billsAlias.'.owner_type', 'player')
|
$player->where($billsAlias.'.owner_type', 'player')
|
||||||
->whereExists(function (Builder $exists) use ($billsAlias, $subtreeIds): void {
|
->whereExists(function (Builder $exists) use ($billsAlias, $actorId): void {
|
||||||
$exists->selectRaw('1')
|
$exists->selectRaw('1')
|
||||||
->from('players')
|
->from('players')
|
||||||
->whereColumn('players.id', $billsAlias.'.owner_id')
|
->whereColumn('players.id', $billsAlias.'.owner_id')
|
||||||
->whereIn('players.agent_node_id', $subtreeIds);
|
->where('players.agent_node_id', $actorId);
|
||||||
});
|
});
|
||||||
})->orWhere(function (Builder $agent) use ($billsAlias, $subtreeIds): void {
|
})->orWhere(function (Builder $agent) use ($billsAlias, $actorId): void {
|
||||||
$agent->where($billsAlias.'.owner_type', 'agent')
|
$agent->where($billsAlias.'.owner_type', 'agent')
|
||||||
->whereIn($billsAlias.'.owner_id', $subtreeIds);
|
->where(function (Builder $edge) use ($billsAlias, $actorId): void {
|
||||||
|
$edge->where($billsAlias.'.owner_id', $actorId)
|
||||||
|
->orWhere(function (Builder $incoming) use ($billsAlias, $actorId): void {
|
||||||
|
$incoming->where($billsAlias.'.counterparty_type', 'agent')
|
||||||
|
->where($billsAlias.'.counterparty_id', $actorId);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $platform) use ($billsAlias, $actorId): void {
|
||||||
|
$platform->where($billsAlias.'.counterparty_type', 'platform')
|
||||||
|
->where($billsAlias.'.owner_id', $actorId);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -147,6 +195,19 @@ final class AdminAgentSettlementScope
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 坏账核销 / 补差冲正仅站点财务或超管(绑定代理不可操作)。 */
|
||||||
|
public static function canPerformFinanceAdjustments(AdminUser $admin): bool
|
||||||
|
{
|
||||||
|
return self::canManageSitePeriods($admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assertCanPerformFinanceAdjustments(AdminUser $admin): void
|
||||||
|
{
|
||||||
|
if (! self::canPerformFinanceAdjustments($admin)) {
|
||||||
|
abort(403, 'agent_bound_cannot_finance_adjust');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function billAccessible(AdminUser $admin, int $settlementBillId): bool
|
public static function billAccessible(AdminUser $admin, int $settlementBillId): bool
|
||||||
{
|
{
|
||||||
$siteIds = $admin->accessibleAdminSiteIds();
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
@@ -157,7 +218,13 @@ final class AdminAgentSettlementScope
|
|||||||
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills as sb')
|
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills as sb')
|
||||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||||
->where('sb.id', $settlementBillId)
|
->where('sb.id', $settlementBillId)
|
||||||
->select(['sb.owner_type', 'sb.owner_id', 'sp.admin_site_id'])
|
->select([
|
||||||
|
'sb.owner_type',
|
||||||
|
'sb.owner_id',
|
||||||
|
'sb.counterparty_type',
|
||||||
|
'sb.counterparty_id',
|
||||||
|
'sp.admin_site_id',
|
||||||
|
])
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($bill === null) {
|
if ($bill === null) {
|
||||||
@@ -168,23 +235,127 @@ final class AdminAgentSettlementScope
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$subtreeIds = self::subtreeAgentNodeIds($admin);
|
$actorId = self::boundAgentNodeId($admin);
|
||||||
if ($subtreeIds === null) {
|
if ($actorId === null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return self::billMatchesDirectEdgeScope($actorId, $bill);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canOperateBill(AdminUser $admin, int $settlementBillId): bool
|
||||||
|
{
|
||||||
|
if (! self::billAccessible($admin, $settlementBillId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::canManageSitePeriods($admin)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills')
|
||||||
|
->where('id', $settlementBillId)
|
||||||
|
->select(['owner_type', 'owner_id', 'counterparty_type', 'counterparty_id', 'net_amount'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($bill === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actorId = self::boundAgentNodeId($admin);
|
||||||
|
if ($actorId === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::billOperableByBoundAgent($actorId, $bill);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assertCanOperateBill(AdminUser $admin, int $settlementBillId): void
|
||||||
|
{
|
||||||
|
abort_if(! self::billAccessible($admin, $settlementBillId), 404);
|
||||||
|
|
||||||
|
if (self::canManageSitePeriods($admin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills')
|
||||||
|
->where('id', $settlementBillId)
|
||||||
|
->select(['owner_type', 'owner_id', 'counterparty_type', 'counterparty_id', 'net_amount'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
abort_if($bill === null, 404);
|
||||||
|
|
||||||
|
$actorId = self::boundAgentNodeId($admin);
|
||||||
|
abort_if($actorId === null, 403, 'agent_cannot_operate_bill');
|
||||||
|
|
||||||
|
abort_if(
|
||||||
|
! self::billOperableByBoundAgent($actorId, $bill),
|
||||||
|
403,
|
||||||
|
(string) $bill->owner_type === 'player' ? 'agent_cannot_operate_player_bill' : 'agent_cannot_operate_bill',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function billMatchesDirectEdgeScope(int $actorId, object $bill): bool
|
||||||
|
{
|
||||||
if ((string) $bill->owner_type === 'player') {
|
if ((string) $bill->owner_type === 'player') {
|
||||||
$agentNodeId = (int) (\Illuminate\Support\Facades\DB::table('players')
|
$agentNodeId = (int) (\Illuminate\Support\Facades\DB::table('players')
|
||||||
->where('id', (int) $bill->owner_id)
|
->where('id', (int) $bill->owner_id)
|
||||||
->value('agent_node_id') ?? 0);
|
->value('agent_node_id') ?? 0);
|
||||||
|
|
||||||
return $agentNodeId > 0 && in_array($agentNodeId, $subtreeIds, true);
|
return $agentNodeId === $actorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((string) $bill->owner_type === 'agent') {
|
if ((string) $bill->owner_type === 'agent') {
|
||||||
return in_array((int) $bill->owner_id, $subtreeIds, true);
|
return self::agentBillOnDirectEdge($actorId, $bill);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function billOperableByBoundAgent(int $actorId, object $bill): bool
|
||||||
|
{
|
||||||
|
if ((string) $bill->owner_type === 'player') {
|
||||||
|
return (string) $bill->counterparty_type === 'agent'
|
||||||
|
&& (int) $bill->counterparty_id === $actorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $bill->owner_type === 'agent') {
|
||||||
|
if (! self::agentBillOnDirectEdge($actorId, $bill)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$payeeType, $payeeId] = self::billPayeeParty($bill);
|
||||||
|
|
||||||
|
return $payeeType === 'agent' && $payeeId === $actorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** net>0:counterparty 为收款方;net<0:owner 为收款方。 */
|
||||||
|
private static function billPayeeParty(object $bill): array
|
||||||
|
{
|
||||||
|
if ((int) $bill->net_amount < 0) {
|
||||||
|
return [(string) $bill->owner_type, (int) $bill->owner_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [(string) $bill->counterparty_type, (int) $bill->counterparty_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function agentBillOnDirectEdge(int $actorId, object $bill): bool
|
||||||
|
{
|
||||||
|
$ownerId = (int) $bill->owner_id;
|
||||||
|
$counterType = (string) $bill->counterparty_type;
|
||||||
|
$counterId = (int) $bill->counterparty_id;
|
||||||
|
|
||||||
|
if ($ownerId === $actorId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($counterType === 'agent' && $counterId === $actorId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $counterType === 'platform' && $ownerId === $actorId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ final class AdminAuthProfile
|
|||||||
* agent: ?array{
|
* agent: ?array{
|
||||||
* id: int,
|
* id: int,
|
||||||
* admin_site_id: int,
|
* admin_site_id: int,
|
||||||
|
* admin_site_name: string,
|
||||||
* site_code: string,
|
* site_code: string,
|
||||||
* path: string,
|
* path: string,
|
||||||
* code: string,
|
* code: string,
|
||||||
@@ -73,6 +74,7 @@ final class AdminAuthProfile
|
|||||||
* @return array{
|
* @return array{
|
||||||
* id: int,
|
* id: int,
|
||||||
* admin_site_id: int,
|
* admin_site_id: int,
|
||||||
|
* admin_site_name: string,
|
||||||
* site_code: string,
|
* site_code: string,
|
||||||
* path: string,
|
* path: string,
|
||||||
* code: string,
|
* code: string,
|
||||||
@@ -93,13 +95,18 @@ final class AdminAuthProfile
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$siteCode = AdminSite::query()->where('id', (int) $node->admin_site_id)->value('code');
|
$site = AdminSite::query()
|
||||||
|
->where('id', (int) $node->admin_site_id)
|
||||||
|
->first(['code', 'name']);
|
||||||
|
$siteCode = is_string($site?->code) ? $site->code : '';
|
||||||
|
$siteName = is_string($site?->name) ? $site->name : '';
|
||||||
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $node->id,
|
'id' => (int) $node->id,
|
||||||
'admin_site_id' => (int) $node->admin_site_id,
|
'admin_site_id' => (int) $node->admin_site_id,
|
||||||
'site_code' => is_string($siteCode) && $siteCode !== '' ? $siteCode : '',
|
'admin_site_name' => $siteName,
|
||||||
|
'site_code' => $siteCode !== '' ? $siteCode : '',
|
||||||
'path' => (string) $node->path,
|
'path' => (string) $node->path,
|
||||||
'code' => (string) $node->code,
|
'code' => (string) $node->code,
|
||||||
'name' => (string) $node->name,
|
'name' => (string) $node->name,
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ final class AdminAuthorizationRegistry
|
|||||||
['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage', 'agent.node.view'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage', 'prd.agent.view']],
|
['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage', 'agent.node.view'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage', 'prd.agent.view']],
|
||||||
['code' => 'admin.agent-nodes.profile.update', 'module_code' => 'agent', 'name' => '代理占成授信更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']],
|
['code' => 'admin.agent-nodes.profile.update', 'module_code' => 'agent', 'name' => '代理占成授信更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']],
|
||||||
['code' => 'admin.settlement-periods.index', 'module_code' => 'settlement', 'name' => '代理账期列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
['code' => 'admin.settlement-periods.index', 'module_code' => 'settlement', 'name' => '代理账期列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||||
|
['code' => 'admin.settlement-periods.open-hints', 'module_code' => 'settlement', 'name' => '开账建议与日历标记', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods/open-hints', 'route_name' => 'api.v1.admin.settlement-periods.open-hints', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||||
['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||||
['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||||
['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ final class AgentDefaultRolePermissions
|
|||||||
'prd.agent.role.view',
|
'prd.agent.role.view',
|
||||||
'prd.agent.user.view',
|
'prd.agent.user.view',
|
||||||
'prd.tickets.view',
|
'prd.tickets.view',
|
||||||
'prd.report.view',
|
|
||||||
'prd.settlement.agent.view',
|
'prd.settlement.agent.view',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\AgentNode;
|
||||||
use App\Models\AgentProfile;
|
use App\Models\AgentProfile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,13 +37,25 @@ final class AgentProfileCapabilityFilter
|
|||||||
'prd.player_freeze.manage',
|
'prd.player_freeze.manage',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private const SETTLEMENT_AGENT_MANAGE_CODE = 'settlement.agent.manage';
|
||||||
|
|
||||||
|
/** 绑定代理主账号均可登记/确认与本节点直属边相关的账单(玩家↔直属代理、代理↔直接上下级)。 */
|
||||||
|
public static function qualifiesForSettlementAgentManage(?AgentNode $node): bool
|
||||||
|
{
|
||||||
|
return $node !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按 Profile 能力收紧或补足登录态 permission_code(平台 agent 角色模板未必含 manage)。
|
* 按 Profile 能力收紧或补足登录态 permission_code(平台 agent 角色模板未必含 manage)。
|
||||||
*
|
*
|
||||||
* @param list<string> $permissionCodes
|
* @param list<string> $permissionCodes
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
|
public static function applyToMenuActionCodes(
|
||||||
|
array $permissionCodes,
|
||||||
|
?AgentProfile $profile,
|
||||||
|
?AgentNode $node = null,
|
||||||
|
): array
|
||||||
{
|
{
|
||||||
$set = [];
|
$set = [];
|
||||||
foreach ($permissionCodes as $code) {
|
foreach ($permissionCodes as $code) {
|
||||||
@@ -71,6 +84,10 @@ final class AgentProfileCapabilityFilter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (self::qualifiesForSettlementAgentManage($node)) {
|
||||||
|
$set[self::SETTLEMENT_AGENT_MANAGE_CODE] = true;
|
||||||
|
}
|
||||||
|
|
||||||
$out = array_keys($set);
|
$out = array_keys($set);
|
||||||
sort($out);
|
sort($out);
|
||||||
|
|
||||||
@@ -81,9 +98,12 @@ final class AgentProfileCapabilityFilter
|
|||||||
* @param list<string> $permissionCodes
|
* @param list<string> $permissionCodes
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
public static function filterMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
|
public static function filterMenuActionCodes(
|
||||||
{
|
array $permissionCodes,
|
||||||
return self::applyToMenuActionCodes($permissionCodes, $profile);
|
?AgentProfile $profile,
|
||||||
|
?AgentNode $node = null,
|
||||||
|
): array {
|
||||||
|
return self::applyToMenuActionCodes($permissionCodes, $profile, $node);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
/** 账期起止统一为日界(startOfDay / endOfDay),供聚合、流水、关账回填共用。 */
|
/** 账期起止边界:开账时规范化写入,关账/聚合/流水筛选共用同一对 UTC 时刻。 */
|
||||||
final class AgentSettlementPeriodWindow
|
final class AgentSettlementPeriodWindow
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -13,8 +14,8 @@ final class AgentSettlementPeriodWindow
|
|||||||
public static function bounds(string $periodStart, string $periodEnd): array
|
public static function bounds(string $periodStart, string $periodEnd): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Carbon::parse($periodStart)->startOfDay(),
|
Carbon::parse($periodStart)->utc(),
|
||||||
Carbon::parse($periodEnd)->endOfDay(),
|
Carbon::parse($periodEnd)->utc(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,4 +28,42 @@ final class AgentSettlementPeriodWindow
|
|||||||
|
|
||||||
return [$start->toDateTimeString(), $end->toDateTimeString()];
|
return [$start->toDateTimeString(), $end->toDateTimeString()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开账 API:支持 `Y-m-d` 或带时刻字符串;前者按 UTC 自然日扩界,后者按 UTC 解释。
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string}
|
||||||
|
*/
|
||||||
|
public static function normalizeInputBounds(string $periodStart, string $periodEnd): array
|
||||||
|
{
|
||||||
|
$startRaw = trim($periodStart);
|
||||||
|
$endRaw = trim($periodEnd);
|
||||||
|
|
||||||
|
if ($startRaw === '' || $endRaw === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'period_start' => ['required'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$startAt = self::isDateOnly($startRaw)
|
||||||
|
? Carbon::parse($startRaw.' 00:00:00', 'UTC')
|
||||||
|
: Carbon::parse($startRaw)->utc();
|
||||||
|
|
||||||
|
$endAt = self::isDateOnly($endRaw)
|
||||||
|
? Carbon::parse($endRaw.' 23:59:59', 'UTC')
|
||||||
|
: Carbon::parse($endRaw)->utc();
|
||||||
|
|
||||||
|
if ($endAt->lessThan($startAt)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'period_end' => ['after:period_start'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$startAt->toDateTimeString(), $endAt->toDateTimeString()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isDateOnly(string $value): bool
|
||||||
|
{
|
||||||
|
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Support\AdminAuthorizationRegistry;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开账建议 API 注册到 admin_api_resources(已有库增量同步,避免 api_resource_not_configured)。
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
private const RESOURCE_CODE = 'admin.settlement-periods.open-hints';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$now = Carbon::now();
|
||||||
|
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
|
||||||
|
$resource = collect(AdminAuthorizationRegistry::resources())
|
||||||
|
->firstWhere('code', self::RESOURCE_CODE);
|
||||||
|
|
||||||
|
if (! is_array($resource)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resourceId = DB::table('admin_api_resources')
|
||||||
|
->where('code', $resource['code'])
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'module_code' => $resource['module_code'],
|
||||||
|
'name' => $resource['name'],
|
||||||
|
'http_method' => $resource['http_method'],
|
||||||
|
'uri_pattern' => $resource['uri_pattern'],
|
||||||
|
'route_name' => $resource['route_name'],
|
||||||
|
'auth_mode' => $resource['auth_mode'],
|
||||||
|
'is_audit_required' => $resource['is_audit_required'],
|
||||||
|
'status' => 1,
|
||||||
|
'meta_json' => null,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($resourceId === null) {
|
||||||
|
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
|
||||||
|
'code' => $resource['code'],
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
DB::table('admin_api_resources')
|
||||||
|
->where('id', (int) $resourceId)
|
||||||
|
->update($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('admin_api_resource_bindings')
|
||||||
|
->where('api_resource_id', (int) $resourceId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
foreach ($resource['permission_codes'] as $permissionCode) {
|
||||||
|
$menuActionId = $menuActionIds[$permissionCode] ?? null;
|
||||||
|
if ($menuActionId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('admin_api_resource_bindings')->insert([
|
||||||
|
'api_resource_id' => (int) $resourceId,
|
||||||
|
'menu_action_id' => (int) $menuActionId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$resourceId = DB::table('admin_api_resources')
|
||||||
|
->where('code', self::RESOURCE_CODE)
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
if ($resourceId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
|
||||||
|
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminRole;
|
||||||
|
use App\Models\AgentNode;
|
||||||
|
use App\Support\AgentDefaultRolePermissions;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
AgentDefaultRolePermissions::ensurePlatformAgentRole();
|
||||||
|
|
||||||
|
AgentNode::query()->each(static function (AgentNode $node): void {
|
||||||
|
$ownerRole = AdminRole::query()
|
||||||
|
->where('owner_agent_id', $node->id)
|
||||||
|
->where('slug', 'agent_owner_'.$node->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($ownerRole === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ownerRole->syncLegacyPermissionSlugs(AgentDefaultRolePermissions::ownerSlugsForNode($node));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// 产品策略调整,回滚不恢复报表中心权限。
|
||||||
|
}
|
||||||
|
};
|
||||||
145
database/seeders/AgentSettlementAdjustmentDemoSeeder.php
Normal file
145
database/seeders/AgentSettlementAdjustmentDemoSeeder.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Services\AgentSettlement\AgentSettlementBillAdjustmentService;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为结算中心「调账 / 冲正」Tab 写入代理账单补差、冲正演示数据。
|
||||||
|
*
|
||||||
|
* ```bash
|
||||||
|
* php artisan db:seed --class="Database\\Seeders\\AgentSettlementAdjustmentDemoSeeder"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
final class AgentSettlementAdjustmentDemoSeeder extends Seeder
|
||||||
|
{
|
||||||
|
private const DEMO_REASON_PREFIX = '[demo]';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
if (app()->environment('production') && ! config('app.debug')) {
|
||||||
|
$this->command?->warn('跳过:production 且 APP_DEBUG=false 时不写入演示调账数据。');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminUserId = (int) (DB::table('admin_users')->where('username', 'admin')->value('id') ?? 0);
|
||||||
|
/** @var AgentSettlementBillAdjustmentService $adjustments */
|
||||||
|
$adjustments = app(AgentSettlementBillAdjustmentService::class);
|
||||||
|
|
||||||
|
$scenarios = [
|
||||||
|
[
|
||||||
|
'original_bill_id' => 35,
|
||||||
|
'amount' => 12000,
|
||||||
|
'adjustment_type' => 'adjustment',
|
||||||
|
'reason' => self::DEMO_REASON_PREFIX.' 代理 good 回水漏计,人工补差',
|
||||||
|
'confirm' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'original_bill_id' => 34,
|
||||||
|
'amount' => -5600,
|
||||||
|
'adjustment_type' => 'reversal',
|
||||||
|
'reason' => self::DEMO_REASON_PREFIX.' 代理 321321 占成分摊复核有误,冲正',
|
||||||
|
'confirm' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'original_bill_id' => 31,
|
||||||
|
'amount' => 800,
|
||||||
|
'adjustment_type' => 'adjustment',
|
||||||
|
'reason' => self::DEMO_REASON_PREFIX.' 代理 good 平台四舍五入补差',
|
||||||
|
'confirm' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
DB::transaction(function () use ($scenarios, $adjustments, $adminUserId): void {
|
||||||
|
foreach ($scenarios as $scenario) {
|
||||||
|
$this->seedScenario($adjustments, $adminUserId, $scenario);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->command?->info('代理结算补差/冲正演示数据已写入(前缀 '.self::DEMO_REASON_PREFIX.')。');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{original_bill_id:int,amount:int,adjustment_type:string,reason:string,confirm:bool} $scenario
|
||||||
|
*/
|
||||||
|
private function seedScenario(
|
||||||
|
AgentSettlementBillAdjustmentService $adjustments,
|
||||||
|
int $adminUserId,
|
||||||
|
array $scenario,
|
||||||
|
): void {
|
||||||
|
$originalBillId = (int) $scenario['original_bill_id'];
|
||||||
|
$reason = (string) $scenario['reason'];
|
||||||
|
|
||||||
|
if ($this->demoAdjustmentExists($originalBillId, $reason)) {
|
||||||
|
$this->command?->line("跳过 bill #{$originalBillId}:已存在相同演示记录。");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
|
||||||
|
if ($original === null) {
|
||||||
|
$this->command?->warn("跳过 bill #{$originalBillId}:原账单不存在。");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $original->bill_type !== 'agent') {
|
||||||
|
$this->command?->warn("跳过 bill #{$originalBillId}:非代理账单。");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($original->locked_at === null) {
|
||||||
|
DB::table('settlement_bills')->where('id', $originalBillId)->update([
|
||||||
|
'locked_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $original->status === 'pending_confirm') {
|
||||||
|
DB::table('settlement_bills')->where('id', $originalBillId)->update([
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'confirmed_at' => now(),
|
||||||
|
'locked_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newBillId = $adjustments->createAdjustment(
|
||||||
|
$originalBillId,
|
||||||
|
(int) $scenario['amount'],
|
||||||
|
(string) $scenario['adjustment_type'],
|
||||||
|
$reason,
|
||||||
|
$adminUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($scenario['confirm']) {
|
||||||
|
$now = now();
|
||||||
|
DB::table('settlement_bills')->where('id', $newBillId)->update([
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'confirmed_at' => $now,
|
||||||
|
'locked_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command?->line(sprintf(
|
||||||
|
'已创建 %s bill #%d ← 原代理账单 #%d(%s)',
|
||||||
|
(string) $scenario['adjustment_type'],
|
||||||
|
$newBillId,
|
||||||
|
$originalBillId,
|
||||||
|
$reason,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function demoAdjustmentExists(int $originalBillId, string $reason): bool
|
||||||
|
{
|
||||||
|
return DB::table('settlement_adjustments')
|
||||||
|
->where('original_bill_id', $originalBillId)
|
||||||
|
->where('reason', $reason)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ return [
|
|||||||
'parent_overdue' => 'The parent agent has overdue bills. This operation is not allowed.',
|
'parent_overdue' => 'The parent agent has overdue bills. This operation is not allowed.',
|
||||||
'period_already_open' => 'A period with this date range is already open. Close it instead of opening again.',
|
'period_already_open' => 'A period with this date range is already open. Close it instead of opening again.',
|
||||||
'period_site_has_open' => 'This site already has an open period. Close it before opening a new one.',
|
'period_site_has_open' => 'This site already has an open period. Close it before opening a new one.',
|
||||||
|
'period_overlaps_existing' => 'This period overlaps an existing one. Adjust the start and end dates.',
|
||||||
'period_not_found' => 'Settlement period not found or not accessible.',
|
'period_not_found' => 'Settlement period not found or not accessible.',
|
||||||
'period_already_closed' => 'This period is already closed.',
|
'period_already_closed' => 'This period is already closed.',
|
||||||
'share_snapshot_missing' => 'Some ledger rows are missing share snapshots. Complete draw settlement first.',
|
'share_snapshot_missing' => 'Some ledger rows are missing share snapshots. Complete draw settlement first.',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ return [
|
|||||||
'parent_overdue' => '上级代理存在逾期未结账单,禁止此操作。',
|
'parent_overdue' => '上级代理存在逾期未结账单,禁止此操作。',
|
||||||
'period_already_open' => '该时间范围的账期已在进行中,请直接关账,勿重复开期。',
|
'period_already_open' => '该时间范围的账期已在进行中,请直接关账,勿重复开期。',
|
||||||
'period_site_has_open' => '本站已有进行中账期,请先关账后再开新账期。',
|
'period_site_has_open' => '本站已有进行中账期,请先关账后再开新账期。',
|
||||||
|
'period_overlaps_existing' => '账期时间与已有账期重叠,请调整起止日期。',
|
||||||
'period_not_found' => '账期不存在或无权访问。',
|
'period_not_found' => '账期不存在或无权访问。',
|
||||||
'period_already_closed' => '该账期已关账,请勿重复操作。',
|
'period_already_closed' => '该账期已关账,请勿重复操作。',
|
||||||
'share_snapshot_missing' => '账期内存在缺少占成快照的流水,无法关账。请先完成开奖结算或联系技术支持。',
|
'share_snapshot_missing' => '账期内存在缺少占成快照的流水,无法关账。请先完成开奖结算或联系技术支持。',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillIndexCo
|
|||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillPaymentController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillPaymentController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillShowController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillShowController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodCloseController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodCloseController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodOpenHintsController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodIndexController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodIndexController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportIndexController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportIndexController;
|
||||||
@@ -18,6 +19,8 @@ use Illuminate\Support\Facades\Route;
|
|||||||
|
|
||||||
Route::middleware('admin.api-resource')
|
Route::middleware('admin.api-resource')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
|
Route::get('settlement-periods/open-hints', AgentSettlementPeriodOpenHintsController::class)
|
||||||
|
->name('api.v1.admin.settlement-periods.open-hints');
|
||||||
Route::get('settlement-periods', AgentSettlementPeriodIndexController::class)
|
Route::get('settlement-periods', AgentSettlementPeriodIndexController::class)
|
||||||
->name('api.v1.admin.settlement-periods.index');
|
->name('api.v1.admin.settlement-periods.index');
|
||||||
Route::post('settlement-periods', AgentSettlementPeriodStoreController::class)
|
Route::post('settlement-periods', AgentSettlementPeriodStoreController::class)
|
||||||
|
|||||||
165
scripts/dev-cleanup-settlement-periods.php
Normal file
165
scripts/dev-cleanup-settlement-periods.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性清理 default_site 重叠账期脏数据,并补齐可演示的连续账期。
|
||||||
|
* 用法:php scripts/dev-cleanup-settlement-periods.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
$app = require __DIR__.'/../bootstrap/app.php';
|
||||||
|
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||||
|
|
||||||
|
$siteId = 1;
|
||||||
|
$keepPeriodIds = [5, 8, 9];
|
||||||
|
$deletePeriodIds = [1, 2, 3, 4, 6, 7];
|
||||||
|
|
||||||
|
DB::transaction(function () use ($siteId, $keepPeriodIds, $deletePeriodIds): void {
|
||||||
|
$deleteBillIds = DB::table('settlement_bills')
|
||||||
|
->whereIn('settlement_period_id', $deletePeriodIds)
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn ($id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($deleteBillIds !== []) {
|
||||||
|
DB::table('payment_records')->whereIn('settlement_bill_id', $deleteBillIds)->delete();
|
||||||
|
DB::table('settlement_adjustments')
|
||||||
|
->where(function ($query) use ($deletePeriodIds, $deleteBillIds): void {
|
||||||
|
$query->whereIn('settlement_period_id', $deletePeriodIds)
|
||||||
|
->orWhereIn('original_bill_id', $deleteBillIds);
|
||||||
|
})
|
||||||
|
->delete();
|
||||||
|
DB::table('settlement_bills')->whereIn('id', $deleteBillIds)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('settlement_periods')->whereIn('id', $deletePeriodIds)->delete();
|
||||||
|
|
||||||
|
// 规范化保留账期(UTC 时刻 = 东八区本地自然日边界,与开账 API 一致)
|
||||||
|
DB::table('settlement_periods')->where('id', 5)->update([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => '2026-05-24 16:00:00',
|
||||||
|
'period_end' => '2026-05-31 15:59:59',
|
||||||
|
'status' => 'closed',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('settlement_periods')->where('id', 9)->update([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => '2026-05-31 16:00:00',
|
||||||
|
'period_end' => '2026-06-07 15:59:59',
|
||||||
|
'status' => 'completed',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('settlement_periods')->where('id', 8)->update([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => '2026-06-07 16:00:00',
|
||||||
|
'period_end' => '2026-06-14 15:59:59',
|
||||||
|
'status' => 'closed',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 6/8–6/14 流水归属第二周账期
|
||||||
|
DB::table('share_ledger')
|
||||||
|
->whereIn('id', [910, 911, 912, 913, 914])
|
||||||
|
->update([
|
||||||
|
'settlement_period_id' => 8,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 待入账流水改到 6/15、6/17,供下一期开账演示
|
||||||
|
DB::table('share_ledger')->where('id', 915)->update([
|
||||||
|
'settlement_period_id' => null,
|
||||||
|
'settled_at' => '2026-06-15 10:00:00',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
DB::table('share_ledger')->where('id', 916)->update([
|
||||||
|
'settlement_period_id' => null,
|
||||||
|
'settled_at' => '2026-06-17 14:30:00',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 第二周账期账单(待收付,用于日历「未结清」标记)
|
||||||
|
DB::table('settlement_bills')->where('settlement_period_id', 8)->delete();
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
DB::table('settlement_bills')->insert([
|
||||||
|
[
|
||||||
|
'settlement_period_id' => 8,
|
||||||
|
'bill_type' => 'player',
|
||||||
|
'owner_type' => 'player',
|
||||||
|
'owner_id' => 5,
|
||||||
|
'counterparty_type' => 'agent',
|
||||||
|
'counterparty_id' => 4,
|
||||||
|
'gross_win_loss' => 8000,
|
||||||
|
'rebate_amount' => 0,
|
||||||
|
'adjustment_amount' => 0,
|
||||||
|
'platform_rounding_adjustment' => 0,
|
||||||
|
'net_amount' => 8000,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'unpaid_amount' => 8000,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'confirmed_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'settlement_period_id' => 8,
|
||||||
|
'bill_type' => 'agent',
|
||||||
|
'owner_type' => 'agent',
|
||||||
|
'owner_id' => 4,
|
||||||
|
'counterparty_type' => 'agent',
|
||||||
|
'counterparty_id' => 1,
|
||||||
|
'gross_win_loss' => 8000,
|
||||||
|
'rebate_amount' => 0,
|
||||||
|
'adjustment_amount' => 0,
|
||||||
|
'platform_rounding_adjustment' => 0,
|
||||||
|
'net_amount' => 6400,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'unpaid_amount' => 6400,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'confirmed_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'settlement_period_id' => 8,
|
||||||
|
'bill_type' => 'agent',
|
||||||
|
'owner_type' => 'agent',
|
||||||
|
'owner_id' => 1,
|
||||||
|
'counterparty_type' => 'platform',
|
||||||
|
'counterparty_id' => 0,
|
||||||
|
'gross_win_loss' => 8000,
|
||||||
|
'rebate_amount' => 0,
|
||||||
|
'adjustment_amount' => 0,
|
||||||
|
'platform_rounding_adjustment' => 0,
|
||||||
|
'net_amount' => 1600,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'unpaid_amount' => 1600,
|
||||||
|
'status' => 'pending_confirm',
|
||||||
|
'confirmed_at' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$rows = DB::select(
|
||||||
|
'SELECT id, period_start::date AS ps, period_end::date AS pe, status FROM settlement_periods WHERE admin_site_id = ? ORDER BY period_start',
|
||||||
|
[$siteId],
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Settlement periods after cleanup:\n";
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
echo sprintf(" #%d %s ~ %s [%s]\n", $row->id, $row->ps, $row->pe, $row->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$unassigned = (int) DB::table('share_ledger as sl')
|
||||||
|
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||||
|
->where('p.site_code', 'default_site')
|
||||||
|
->whereNull('sl.settlement_period_id')
|
||||||
|
->whereNull('sl.reversal_of_id')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
echo "\nUnassigned share_ledger (default_site): {$unassigned}\n";
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Models\AgentNode;
|
use App\Models\AgentNode;
|
||||||
|
use App\Models\Player;
|
||||||
use App\Support\AgentPlatformRole;
|
use App\Support\AgentPlatformRole;
|
||||||
|
use App\Support\PlayerFundingMode;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -65,3 +67,160 @@ test('agent dashboard returns agent overview for operator with dashboard permiss
|
|||||||
->assertJsonPath('data.agent_overview.agent_node_id', $branch->id)
|
->assertJsonPath('data.agent_overview.agent_node_id', $branch->id)
|
||||||
->assertJsonPath('data.agent_overview.agent_code', 'dash-branch');
|
->assertJsonPath('data.agent_overview.agent_code', 'dash-branch');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('agent dashboard profit uses share profit not team house gross', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||||
|
$super = AdminUser::query()->create([
|
||||||
|
'username' => 'super_dash_share',
|
||||||
|
'name' => 'Super',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($super);
|
||||||
|
|
||||||
|
$branch = $service->createChild($super, [
|
||||||
|
'parent_id' => $rootId,
|
||||||
|
'code' => 'dash-share-branch',
|
||||||
|
'name' => 'Dash Share Branch',
|
||||||
|
'can_create_player' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first();
|
||||||
|
expect($operator)->not->toBeNull();
|
||||||
|
|
||||||
|
$player = Player::query()->create([
|
||||||
|
'site_code' => $siteCode,
|
||||||
|
'site_player_id' => 'native:dash-share-player',
|
||||||
|
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||||
|
'username' => 'dash_share_player',
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
'agent_node_id' => $branch->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draw = \App\Models\Draw::query()->create([
|
||||||
|
'draw_no' => 'DRAW-DASH-SHARE',
|
||||||
|
'business_date' => now()->toDateString(),
|
||||||
|
'sequence_no' => random_int(1, 9999),
|
||||||
|
'status' => \App\Lottery\DrawStatus::Open->value,
|
||||||
|
'current_result_version' => 0,
|
||||||
|
'settle_version' => 0,
|
||||||
|
'is_reopened' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||||
|
'order_no' => 'ORD-DASH-SHARE',
|
||||||
|
'player_id' => $player->id,
|
||||||
|
'draw_id' => $draw->id,
|
||||||
|
'currency_code' => 'NPR',
|
||||||
|
'total_bet_amount' => 10_000,
|
||||||
|
'total_rebate_amount' => 0,
|
||||||
|
'total_actual_deduct' => 10_000,
|
||||||
|
'total_estimated_payout' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'submit_source' => 'h5',
|
||||||
|
'client_trace_id' => null,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticketItemId = (int) DB::table('ticket_items')->insertGetId([
|
||||||
|
'ticket_no' => 'T-DASH-SHARE',
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'player_id' => $player->id,
|
||||||
|
'draw_id' => $draw->id,
|
||||||
|
'original_number' => null,
|
||||||
|
'normalized_number' => '1234',
|
||||||
|
'play_code' => 'big',
|
||||||
|
'dimension' => 2,
|
||||||
|
'digit_slot' => null,
|
||||||
|
'bet_mode' => null,
|
||||||
|
'unit_bet_amount' => 10_000,
|
||||||
|
'total_bet_amount' => 10_000,
|
||||||
|
'rebate_rate_snapshot' => 0,
|
||||||
|
'commission_rate_snapshot' => 0,
|
||||||
|
'actual_deduct_amount' => 10_000,
|
||||||
|
'odds_snapshot_json' => null,
|
||||||
|
'rule_snapshot_json' => null,
|
||||||
|
'combination_count' => 1,
|
||||||
|
'estimated_max_payout' => 0,
|
||||||
|
'risk_locked_amount' => 0,
|
||||||
|
'status' => 'settled_lose',
|
||||||
|
'win_amount' => 0,
|
||||||
|
'jackpot_win_amount' => 0,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$settledAt = now()->toDateTimeString();
|
||||||
|
DB::table('share_ledger')->insert([
|
||||||
|
'ticket_item_id' => $ticketItemId,
|
||||||
|
'player_id' => $player->id,
|
||||||
|
'agent_node_id' => $branch->id,
|
||||||
|
'agent_path' => json_encode([$branch->id]),
|
||||||
|
'share_snapshot' => json_encode([
|
||||||
|
'total_shares' => [(string) $branch->code => 30.0],
|
||||||
|
'chain_codes' => [(string) $branch->code],
|
||||||
|
]),
|
||||||
|
'game_win_loss' => 1_000,
|
||||||
|
'basic_rebate' => 0,
|
||||||
|
'shared_net_win_loss' => 1_000,
|
||||||
|
'allocations_json' => json_encode([
|
||||||
|
(string) $branch->code => 300,
|
||||||
|
'platform' => 700,
|
||||||
|
]),
|
||||||
|
'settled_at' => $settledAt,
|
||||||
|
'created_at' => $settledAt,
|
||||||
|
'updated_at' => $settledAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/dashboard')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.agent_overview.profit_scope', 'share_profit')
|
||||||
|
->assertJsonPath('data.agent_overview.today_profit_minor', 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent bound admin cannot open platform pnl settlement report', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||||
|
$super = AdminUser::query()->create([
|
||||||
|
'username' => 'super_report_block',
|
||||||
|
'name' => 'Super',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($super);
|
||||||
|
|
||||||
|
$branch = $service->createChild($super, [
|
||||||
|
'parent_id' => $rootId,
|
||||||
|
'code' => 'report-block-branch',
|
||||||
|
'name' => 'Report Block Branch',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first();
|
||||||
|
expect($operator)->not->toBeNull();
|
||||||
|
|
||||||
|
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => now()->subDay()->toDateString(),
|
||||||
|
'period_end' => now()->addDay()->toDateString(),
|
||||||
|
'status' => 'open',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/settlement-reports?type=platform_pnl&settlement_period_id='.$periodId)
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ test('agent profile switches strip create player and child manage from effective
|
|||||||
|
|
||||||
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeFalse();
|
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeFalse();
|
||||||
expect($fresh->hasPermissionCode('service.players.manage'))->toBeFalse();
|
expect($fresh->hasPermissionCode('service.players.manage'))->toBeFalse();
|
||||||
|
expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue();
|
||||||
|
expect($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage');
|
||||||
expect($profile['agent']['can_create_child_agent'])->toBeFalse();
|
expect($profile['agent']['can_create_child_agent'])->toBeFalse();
|
||||||
expect($profile['agent']['can_create_player'])->toBeFalse();
|
expect($profile['agent']['can_create_player'])->toBeFalse();
|
||||||
});
|
});
|
||||||
@@ -123,5 +125,96 @@ test('agent profile switches on grant create capabilities even when platform age
|
|||||||
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue();
|
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue();
|
||||||
expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue();
|
expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue();
|
||||||
expect($fresh->adminPermissionSlugs())->toContain('prd.agent.manage')
|
expect($fresh->adminPermissionSlugs())->toContain('prd.agent.manage')
|
||||||
->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage');
|
->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage')
|
||||||
|
->and($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage');
|
||||||
|
expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('line root bound agent receives settlement manage at login', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'settle_root_agent',
|
||||||
|
'name' => 'Settle Root Agent',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('admin_user_agents')->insert([
|
||||||
|
'admin_user_id' => $admin->id,
|
||||||
|
'agent_node_id' => $rootId,
|
||||||
|
'is_primary' => true,
|
||||||
|
'granted_at' => now(),
|
||||||
|
]);
|
||||||
|
$admin->syncPrimaryPlatformAgentRole($rootId);
|
||||||
|
|
||||||
|
$fresh = $admin->fresh();
|
||||||
|
|
||||||
|
expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue();
|
||||||
|
expect($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent with downline children receives settlement manage at login', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
|
||||||
|
$parent = AgentNode::query()->create([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'parent_id' => $rootId,
|
||||||
|
'path' => '/',
|
||||||
|
'depth' => 1,
|
||||||
|
'code' => 'settle-parent',
|
||||||
|
'name' => 'Settle Parent',
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
$parent->path = "/{$rootId}/{$parent->id}/";
|
||||||
|
$parent->save();
|
||||||
|
|
||||||
|
$child = AgentNode::query()->create([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'parent_id' => $parent->id,
|
||||||
|
'path' => "/{$rootId}/{$parent->id}/",
|
||||||
|
'depth' => 2,
|
||||||
|
'code' => 'settle-child',
|
||||||
|
'name' => 'Settle Child',
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
$child->path = "/{$rootId}/{$parent->id}/{$child->id}/";
|
||||||
|
$child->save();
|
||||||
|
|
||||||
|
AgentProfile::query()->create([
|
||||||
|
'agent_node_id' => $parent->id,
|
||||||
|
'total_share_rate' => 20,
|
||||||
|
'credit_limit' => 0,
|
||||||
|
'allocated_credit' => 0,
|
||||||
|
'used_credit' => 0,
|
||||||
|
'rebate_limit' => 0,
|
||||||
|
'default_player_rebate' => 0,
|
||||||
|
'can_grant_extra_rebate' => false,
|
||||||
|
'can_create_child_agent' => false,
|
||||||
|
'can_create_player' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'settle_parent_agent',
|
||||||
|
'name' => 'Settle Parent Agent',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('admin_user_agents')->insert([
|
||||||
|
'admin_user_id' => $admin->id,
|
||||||
|
'agent_node_id' => $parent->id,
|
||||||
|
'is_primary' => true,
|
||||||
|
'granted_at' => now(),
|
||||||
|
]);
|
||||||
|
$admin->syncPrimaryPlatformAgentRole($parent->id);
|
||||||
|
|
||||||
|
$fresh = $admin->fresh();
|
||||||
|
|
||||||
|
expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue();
|
||||||
|
expect($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,3 +31,162 @@ test('settlement bills index api resource is configured after migrations', funct
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.items', fn ($items) => is_array($items));
|
->assertJsonPath('data.items', fn ($items) => is_array($items));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('settlement bill show returns enriched party labels', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
$childId = (int) DB::table('agent_nodes')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'parent_id' => $rootId,
|
||||||
|
'code' => 'bill_show_child',
|
||||||
|
'name' => 'Bill Show Child',
|
||||||
|
'depth' => 1,
|
||||||
|
'path' => '/'.$rootId.'/',
|
||||||
|
'status' => 0,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
DB::table('agent_nodes')->where('id', $childId)->update([
|
||||||
|
'path' => '/'.$rootId.'/'.$childId.'/',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => now()->subWeek(),
|
||||||
|
'period_end' => now(),
|
||||||
|
'status' => 'closed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$billId = (int) DB::table('settlement_bills')->insertGetId([
|
||||||
|
'settlement_period_id' => $periodId,
|
||||||
|
'bill_type' => 'agent',
|
||||||
|
'owner_type' => 'agent',
|
||||||
|
'owner_id' => $childId,
|
||||||
|
'counterparty_type' => 'agent',
|
||||||
|
'counterparty_id' => $rootId,
|
||||||
|
'net_amount' => 6400,
|
||||||
|
'unpaid_amount' => 6400,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'bill_show_super',
|
||||||
|
'name' => 'Bill Show Super',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($admin);
|
||||||
|
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$rootName = (string) DB::table('agent_nodes')->where('id', $rootId)->value('name');
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/settlement-bills/'.$billId)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.bill.owner_party_label', 'Bill Show Child')
|
||||||
|
->assertJsonPath('data.bill.superior_agent_label', $rootName);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/settlement-bills?bill_id='.$billId)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items.0.owner_party_label', 'Bill Show Child')
|
||||||
|
->assertJsonPath('data.items.0.superior_agent_label', $rootName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settlement bill show returns downline share breakdown for parent agent bill', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
$parentId = (int) DB::table('agent_nodes')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'parent_id' => $rootId,
|
||||||
|
'code' => 'downline_parent',
|
||||||
|
'name' => 'Downline Parent',
|
||||||
|
'depth' => 1,
|
||||||
|
'path' => '/'.$rootId.'/',
|
||||||
|
'status' => 0,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
DB::table('agent_nodes')->where('id', $parentId)->update([
|
||||||
|
'path' => '/'.$rootId.'/'.$parentId.'/',
|
||||||
|
]);
|
||||||
|
$childId = (int) DB::table('agent_nodes')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'code' => 'downline_child',
|
||||||
|
'name' => 'Downline Child',
|
||||||
|
'depth' => 2,
|
||||||
|
'path' => '/'.$rootId.'/'.$parentId.'/',
|
||||||
|
'status' => 0,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
DB::table('agent_nodes')->where('id', $childId)->update([
|
||||||
|
'path' => '/'.$rootId.'/'.$parentId.'/'.$childId.'/',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => now()->subWeek(),
|
||||||
|
'period_end' => now(),
|
||||||
|
'status' => 'closed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('settlement_bills')->insert([
|
||||||
|
'settlement_period_id' => $periodId,
|
||||||
|
'bill_type' => 'agent',
|
||||||
|
'owner_type' => 'agent',
|
||||||
|
'owner_id' => $childId,
|
||||||
|
'counterparty_type' => 'agent',
|
||||||
|
'counterparty_id' => $parentId,
|
||||||
|
'net_amount' => 3916,
|
||||||
|
'unpaid_amount' => 3916,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'meta_json' => json_encode(['share_profit' => 484]),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parentBillId = (int) DB::table('settlement_bills')->insertGetId([
|
||||||
|
'settlement_period_id' => $periodId,
|
||||||
|
'bill_type' => 'agent',
|
||||||
|
'owner_type' => 'agent',
|
||||||
|
'owner_id' => $parentId,
|
||||||
|
'counterparty_type' => 'agent',
|
||||||
|
'counterparty_id' => $rootId,
|
||||||
|
'gross_win_loss' => 4400,
|
||||||
|
'net_amount' => 3520,
|
||||||
|
'unpaid_amount' => 3520,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'meta_json' => json_encode(['share_profit' => 396]),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'downline_share_super',
|
||||||
|
'name' => 'Downline Share Super',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($admin);
|
||||||
|
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/settlement-bills/'.$parentBillId)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.downline_shares.total', 484)
|
||||||
|
->assertJsonPath('data.downline_shares.items.0.owner_label', 'Downline Child')
|
||||||
|
->assertJsonPath('data.downline_shares.items.0.share_profit', 484);
|
||||||
|
});
|
||||||
|
|||||||
@@ -88,3 +88,56 @@ test('admin can write off player bill bad debt and complete period when all sett
|
|||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bound agent with settlement manage cannot write off bad debt', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
|
||||||
|
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => now()->subDays(7),
|
||||||
|
'period_end' => now(),
|
||||||
|
'status' => 'closed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$billId = (int) DB::table('settlement_bills')->insertGetId([
|
||||||
|
'settlement_period_id' => $periodId,
|
||||||
|
'bill_type' => 'agent',
|
||||||
|
'owner_type' => 'agent',
|
||||||
|
'owner_id' => $rootId,
|
||||||
|
'counterparty_type' => 'platform',
|
||||||
|
'counterparty_id' => 0,
|
||||||
|
'net_amount' => 5000,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'unpaid_amount' => 5000,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'confirmed_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'bad_debt_bound_root',
|
||||||
|
'name' => 'Bad Debt Bound Root',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('admin_user_agents')->insert([
|
||||||
|
'admin_user_id' => $admin->id,
|
||||||
|
'agent_node_id' => $rootId,
|
||||||
|
'is_primary' => true,
|
||||||
|
'granted_at' => now(),
|
||||||
|
]);
|
||||||
|
$admin->syncPrimaryPlatformAgentRole($rootId);
|
||||||
|
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson('/api/v1/admin/settlement-bills/'.$billId.'/bad-debt-write-off', [
|
||||||
|
'reason' => 'should fail',
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|||||||
351
tests/Feature/AgentSettlementBillDirectEdgeScopeTest.php
Normal file
351
tests/Feature/AgentSettlementBillDirectEdgeScopeTest.php
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Support\PlayerFundingMode;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
|
$this->artisan('lottery:agent-roles-sync')->assertExitCode(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bound parent agent cannot see player bills under direct child agent', function (): void {
|
||||||
|
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
|
||||||
|
|
||||||
|
$playerBillId = insertPlayerBill($periodId, $player->id, $child->id, 4400);
|
||||||
|
insertAgentBill($periodId, $child->id, $parent->id, 4400);
|
||||||
|
insertAgentBill($periodId, $parent->id, $rootId, 3520);
|
||||||
|
|
||||||
|
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_scope');
|
||||||
|
$childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_scope');
|
||||||
|
$parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
expect(\App\Support\AdminAgentSettlementScope::billAccessible($childAdmin, $playerBillId))->toBeTrue()
|
||||||
|
->and(\App\Support\AdminAgentSettlementScope::billAccessible($parentAdmin, $playerBillId))->toBeFalse();
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$parentToken)
|
||||||
|
->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items', function (array $items) use ($playerBillId): bool {
|
||||||
|
$ids = array_column($items, 'id');
|
||||||
|
|
||||||
|
return ! in_array($playerBillId, $ids, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$parentToken)
|
||||||
|
->getJson('/api/v1/admin/settlement-bills/'.$playerBillId)
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
$this->actingAs($childAdmin, 'sanctum')
|
||||||
|
->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId.'&bill_id='.$playerBillId)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items.0.id', $playerBillId);
|
||||||
|
|
||||||
|
$this->actingAs($childAdmin, 'sanctum')
|
||||||
|
->getJson('/api/v1/admin/settlement-bills/'.$playerBillId)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.bill.id', $playerBillId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bound parent agent sees direct child agent bill but not deeper chain bills', function (): void {
|
||||||
|
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
|
||||||
|
|
||||||
|
$childToParentBillId = insertAgentBill($periodId, $child->id, $parent->id, 4400);
|
||||||
|
$parentToRootBillId = insertAgentBill($periodId, $parent->id, $rootId, 3520);
|
||||||
|
|
||||||
|
$super = AdminUser::query()->create([
|
||||||
|
'username' => 'edge_grand_super_'.uniqid(),
|
||||||
|
'name' => 'Super',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($super);
|
||||||
|
|
||||||
|
$grandchild = app(\App\Services\Agent\AgentNodeService::class)->createChild(
|
||||||
|
$super,
|
||||||
|
[
|
||||||
|
'parent_id' => $child->id,
|
||||||
|
'code' => 'edge-grandchild',
|
||||||
|
'name' => 'Edge Grandchild',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$deepBillId = insertAgentBill($periodId, $grandchild->id, $child->id, 1200);
|
||||||
|
|
||||||
|
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_edge');
|
||||||
|
$parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$parentToken)
|
||||||
|
->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId)
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$ids = array_column($response->json('data.items'), 'id');
|
||||||
|
|
||||||
|
expect($ids)->toContain($childToParentBillId)
|
||||||
|
->and($ids)->toContain($parentToRootBillId)
|
||||||
|
->and($ids)->not->toContain($deepBillId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bound parent agent cannot confirm or pay player bill under child', function (): void {
|
||||||
|
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
|
||||||
|
|
||||||
|
$playerBillId = (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' => $child->id,
|
||||||
|
'net_amount' => 4400,
|
||||||
|
'unpaid_amount' => 4400,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'status' => 'pending_confirm',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_pay');
|
||||||
|
$parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$parentToken)
|
||||||
|
->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/confirm')
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
DB::table('settlement_bills')->where('id', $playerBillId)->update(['status' => 'confirmed']);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$parentToken)
|
||||||
|
->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/payments', [
|
||||||
|
'amount' => 4400,
|
||||||
|
])
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parent agent can register payment on child agent bill where parent is payee', function (): void {
|
||||||
|
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
|
||||||
|
|
||||||
|
$agentBillId = (int) DB::table('settlement_bills')->insertGetId([
|
||||||
|
'settlement_period_id' => $periodId,
|
||||||
|
'bill_type' => 'agent',
|
||||||
|
'owner_type' => 'agent',
|
||||||
|
'owner_id' => $child->id,
|
||||||
|
'counterparty_type' => 'agent',
|
||||||
|
'counterparty_id' => $parent->id,
|
||||||
|
'net_amount' => 4400,
|
||||||
|
'unpaid_amount' => 4400,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_payee');
|
||||||
|
$childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_payer');
|
||||||
|
|
||||||
|
$this->actingAs($parentAdmin, 'sanctum')
|
||||||
|
->postJson('/api/v1/admin/settlement-bills/'.$agentBillId.'/payments', ['amount' => 4400])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($childAdmin, 'sanctum')
|
||||||
|
->postJson('/api/v1/admin/settlement-bills/'.$agentBillId.'/payments', ['amount' => 4400])
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settlement credit ledger excludes players under child agent for parent viewer', function (): void {
|
||||||
|
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
|
||||||
|
|
||||||
|
DB::table('credit_ledger')->insert([
|
||||||
|
'owner_type' => 'player',
|
||||||
|
'owner_id' => $player->id,
|
||||||
|
'amount' => -100,
|
||||||
|
'reason' => 'bet_hold',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_ledger');
|
||||||
|
|
||||||
|
$this->actingAs($parentAdmin, 'sanctum')
|
||||||
|
->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items', fn (array $items): bool => count($items) === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct child agent can confirm and pay own player bill', function (): void {
|
||||||
|
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
|
||||||
|
|
||||||
|
$playerBillId = (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' => $child->id,
|
||||||
|
'net_amount' => 4400,
|
||||||
|
'unpaid_amount' => 4400,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'status' => 'pending_confirm',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_pay');
|
||||||
|
|
||||||
|
$this->actingAs($childAdmin, 'sanctum')
|
||||||
|
->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/confirm')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($childAdmin, 'sanctum')
|
||||||
|
->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/payments', [
|
||||||
|
'amount' => 4400,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.bill.status', 'settled');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: int, 1: string, 2: int, 3: \App\Models\AgentNode, 4: \App\Models\AgentNode, 5: Player, 6: int}
|
||||||
|
*/
|
||||||
|
function seedDirectEdgeSettlementFixture(): array
|
||||||
|
{
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
|
||||||
|
$super = AdminUser::query()->create([
|
||||||
|
'username' => 'edge_scope_super_'.uniqid(),
|
||||||
|
'name' => 'Super',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($super);
|
||||||
|
|
||||||
|
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||||
|
$parent = $service->createChild($super, [
|
||||||
|
'parent_id' => $rootId,
|
||||||
|
'code' => 'edge-parent-'.uniqid(),
|
||||||
|
'name' => 'Edge Parent',
|
||||||
|
]);
|
||||||
|
$child = $service->createChild($super, [
|
||||||
|
'parent_id' => $parent->id,
|
||||||
|
'code' => 'edge-child-'.uniqid(),
|
||||||
|
'name' => 'Edge Child',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$player = Player::query()->create([
|
||||||
|
'site_code' => $siteCode,
|
||||||
|
'site_player_id' => 'native:edge-'.uniqid(),
|
||||||
|
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||||
|
'username' => 'edge_player_'.uniqid(),
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
'agent_node_id' => $child->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => now()->subWeek(),
|
||||||
|
'period_end' => now(),
|
||||||
|
'status' => 'closed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertPlayerBill(int $periodId, int $playerId, int $agentId, int $amount): int
|
||||||
|
{
|
||||||
|
return (int) DB::table('settlement_bills')->insertGetId([
|
||||||
|
'settlement_period_id' => $periodId,
|
||||||
|
'bill_type' => 'player',
|
||||||
|
'owner_type' => 'player',
|
||||||
|
'owner_id' => $playerId,
|
||||||
|
'counterparty_type' => 'agent',
|
||||||
|
'counterparty_id' => $agentId,
|
||||||
|
'net_amount' => $amount,
|
||||||
|
'unpaid_amount' => $amount,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAgentBill(int $periodId, int $ownerId, int $counterpartyId, int $amount): int
|
||||||
|
{
|
||||||
|
return (int) DB::table('settlement_bills')->insertGetId([
|
||||||
|
'settlement_period_id' => $periodId,
|
||||||
|
'bill_type' => 'agent',
|
||||||
|
'owner_type' => 'agent',
|
||||||
|
'owner_id' => $ownerId,
|
||||||
|
'counterparty_type' => 'agent',
|
||||||
|
'counterparty_id' => $counterpartyId,
|
||||||
|
'net_amount' => $amount,
|
||||||
|
'unpaid_amount' => $amount,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBoundSettlementAgentAdmin(int $siteId, \App\Models\AgentNode $agent, string $prefix): AdminUser
|
||||||
|
{
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => $prefix.'_'.uniqid(),
|
||||||
|
'name' => ucfirst($prefix),
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('admin_user_agents')->insert([
|
||||||
|
'admin_user_id' => $admin->id,
|
||||||
|
'agent_node_id' => (int) $agent->id,
|
||||||
|
'is_primary' => true,
|
||||||
|
'granted_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin->syncPrimaryPlatformAgentRole((int) $agent->id);
|
||||||
|
|
||||||
|
return $admin->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantSettlementManageToAgentAdmin(AdminUser $admin, \App\Models\AgentNode $agent): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$roleId = DB::table('admin_roles')->insertGetId([
|
||||||
|
'slug' => 'settle_manage_'.$admin->id,
|
||||||
|
'code' => 'settle_manage_'.$admin->id,
|
||||||
|
'name' => 'Settlement Manage',
|
||||||
|
'status' => 1,
|
||||||
|
'is_system' => false,
|
||||||
|
'sort_order' => 0,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$actionIds = DB::table('admin_menu_actions')
|
||||||
|
->whereIn('permission_code', ['settlement.agent.manage', 'prd.settlement.agent.manage'])
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
foreach ($actionIds as $actionId) {
|
||||||
|
DB::table('admin_role_menu_actions')->insert([
|
||||||
|
'role_id' => $roleId,
|
||||||
|
'menu_action_id' => (int) $actionId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('admin_user_agent_roles')->insert([
|
||||||
|
'admin_user_id' => $admin->id,
|
||||||
|
'agent_node_id' => (int) $agent->id,
|
||||||
|
'role_id' => $roleId,
|
||||||
|
'granted_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -7,8 +7,25 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('settlement payments and adjustments index return items', function (): void {
|
test('settlement payments and adjustments index return items', function (): void {
|
||||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
$playerId = (int) DB::table('players')->insertGetId([
|
||||||
|
'site_code' => (string) DB::table('admin_sites')->where('id', $siteId)->value('code'),
|
||||||
|
'site_player_id' => 'lists-player',
|
||||||
|
'auth_source' => 'lottery_native',
|
||||||
|
'funding_mode' => 'credit',
|
||||||
|
'username' => 'lists_player',
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
'agent_node_id' => $rootId,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||||
'admin_site_id' => $siteId,
|
'admin_site_id' => $siteId,
|
||||||
'period_start' => now()->subWeek(),
|
'period_start' => now()->subWeek(),
|
||||||
@@ -22,9 +39,9 @@ test('settlement payments and adjustments index return items', function (): void
|
|||||||
'settlement_period_id' => $periodId,
|
'settlement_period_id' => $periodId,
|
||||||
'bill_type' => 'player',
|
'bill_type' => 'player',
|
||||||
'owner_type' => 'player',
|
'owner_type' => 'player',
|
||||||
'owner_id' => 1,
|
'owner_id' => $playerId,
|
||||||
'counterparty_type' => 'agent',
|
'counterparty_type' => 'agent',
|
||||||
'counterparty_id' => 1,
|
'counterparty_id' => $rootId,
|
||||||
'net_amount' => 1000,
|
'net_amount' => 1000,
|
||||||
'unpaid_amount' => 0,
|
'unpaid_amount' => 0,
|
||||||
'paid_amount' => 1000,
|
'paid_amount' => 1000,
|
||||||
@@ -36,9 +53,9 @@ test('settlement payments and adjustments index return items', function (): void
|
|||||||
DB::table('payment_records')->insert([
|
DB::table('payment_records')->insert([
|
||||||
'settlement_bill_id' => $billId,
|
'settlement_bill_id' => $billId,
|
||||||
'payer_type' => 'player',
|
'payer_type' => 'player',
|
||||||
'payer_id' => 1,
|
'payer_id' => $playerId,
|
||||||
'payee_type' => 'agent',
|
'payee_type' => 'agent',
|
||||||
'payee_id' => 1,
|
'payee_id' => $rootId,
|
||||||
'amount' => 1000,
|
'amount' => 1000,
|
||||||
'method' => 'cash',
|
'method' => 'cash',
|
||||||
'status' => 'confirmed',
|
'status' => 'confirmed',
|
||||||
|
|||||||
197
tests/Feature/AgentSettlementPeriodOpenHintsTest.php
Normal file
197
tests/Feature/AgentSettlementPeriodOpenHintsTest.php
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Lottery\DrawStatus;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settlement period open hints api resource is configured after migrations', function (): void {
|
||||||
|
expect(
|
||||||
|
DB::table('admin_api_resources')
|
||||||
|
->where('route_name', 'api.v1.admin.settlement-periods.open-hints')
|
||||||
|
->where('status', 1)
|
||||||
|
->exists(),
|
||||||
|
)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settlement period open hints returns suggested range and calendar markers', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||||
|
|
||||||
|
DB::table('settlement_periods')->insert([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => '2026-04-30 16:00:00',
|
||||||
|
'period_end' => '2026-05-31 15:59:59',
|
||||||
|
'status' => 'closed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$unpaidPeriodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => '2026-03-31 16:00:00',
|
||||||
|
'period_end' => '2026-04-03 15:59:59',
|
||||||
|
'status' => 'closed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('settlement_bills')->insert([
|
||||||
|
'settlement_period_id' => $unpaidPeriodId,
|
||||||
|
'bill_type' => 'agent',
|
||||||
|
'owner_type' => 'agent',
|
||||||
|
'owner_id' => $rootId,
|
||||||
|
'counterparty_type' => 'platform',
|
||||||
|
'counterparty_id' => 0,
|
||||||
|
'net_amount' => 1000,
|
||||||
|
'unpaid_amount' => 1000,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$player = Player::query()->create([
|
||||||
|
'site_code' => $siteCode,
|
||||||
|
'agent_node_id' => $rootId,
|
||||||
|
'site_player_id' => 'hints-player',
|
||||||
|
'username' => 'hints_player',
|
||||||
|
'nickname' => null,
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draw = Draw::query()->create([
|
||||||
|
'draw_no' => 'DRAW-HINTS',
|
||||||
|
'business_date' => '2026-06-05',
|
||||||
|
'sequence_no' => 1,
|
||||||
|
'status' => DrawStatus::Open->value,
|
||||||
|
'current_result_version' => 0,
|
||||||
|
'settle_version' => 0,
|
||||||
|
'is_reopened' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||||
|
'order_no' => 'ORD-HINTS',
|
||||||
|
'player_id' => $player->id,
|
||||||
|
'draw_id' => $draw->id,
|
||||||
|
'currency_code' => 'NPR',
|
||||||
|
'total_bet_amount' => 10_000,
|
||||||
|
'total_rebate_amount' => 0,
|
||||||
|
'total_actual_deduct' => 10_000,
|
||||||
|
'total_estimated_payout' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'submit_source' => 'h5',
|
||||||
|
'client_trace_id' => null,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$itemId = (int) DB::table('ticket_items')->insertGetId([
|
||||||
|
'ticket_no' => 'T-HINTS',
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'player_id' => $player->id,
|
||||||
|
'draw_id' => $draw->id,
|
||||||
|
'original_number' => null,
|
||||||
|
'normalized_number' => '1234',
|
||||||
|
'play_code' => 'big',
|
||||||
|
'dimension' => 2,
|
||||||
|
'digit_slot' => null,
|
||||||
|
'bet_mode' => null,
|
||||||
|
'unit_bet_amount' => 10_000,
|
||||||
|
'total_bet_amount' => 10_000,
|
||||||
|
'rebate_rate_snapshot' => 0,
|
||||||
|
'commission_rate_snapshot' => 0,
|
||||||
|
'actual_deduct_amount' => 10_000,
|
||||||
|
'odds_snapshot_json' => null,
|
||||||
|
'rule_snapshot_json' => null,
|
||||||
|
'combination_count' => 1,
|
||||||
|
'estimated_max_payout' => 0,
|
||||||
|
'risk_locked_amount' => 0,
|
||||||
|
'status' => 'settled_lose',
|
||||||
|
'win_amount' => 0,
|
||||||
|
'jackpot_win_amount' => 0,
|
||||||
|
'settled_at' => null,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$settledAt = '2026-06-05 12:00:00';
|
||||||
|
DB::table('share_ledger')->insert([
|
||||||
|
'ticket_item_id' => $itemId,
|
||||||
|
'player_id' => $player->id,
|
||||||
|
'agent_node_id' => $rootId,
|
||||||
|
'agent_path' => json_encode([$rootId]),
|
||||||
|
'share_snapshot' => json_encode(['total_shares' => [$siteCode => 100]]),
|
||||||
|
'game_win_loss' => 1000,
|
||||||
|
'basic_rebate' => 0,
|
||||||
|
'shared_net_win_loss' => 1000,
|
||||||
|
'allocations_json' => json_encode([]),
|
||||||
|
'settled_at' => $settledAt,
|
||||||
|
'settlement_period_id' => null,
|
||||||
|
'created_at' => $settledAt,
|
||||||
|
'updated_at' => $settledAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'open_hints_admin',
|
||||||
|
'name' => 'Hints',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($admin);
|
||||||
|
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/settlement-periods/open-hints?admin_site_id='.$siteId)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.suggested_start', '2026-06-01')
|
||||||
|
->assertJsonPath('data.suggested_end', '2026-06-05')
|
||||||
|
->assertJsonPath('data.pending_activity_dates.0', '2026-06-05')
|
||||||
|
->assertJsonFragment(['2026-05-01'])
|
||||||
|
->assertJsonFragment(['2026-04-01'])
|
||||||
|
->assertJsonFragment(['2026-04-02'])
|
||||||
|
->assertJsonFragment(['2026-04-03']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settlement period open hints does not suggest range overlapping occupied periods', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
|
||||||
|
DB::table('settlement_periods')->insert([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => '2026-05-31 16:00:00',
|
||||||
|
'period_end' => '2026-06-30 15:59:59',
|
||||||
|
'status' => 'closed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'open_hints_overlap_admin',
|
||||||
|
'name' => 'Hints Overlap',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($admin);
|
||||||
|
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/settlement-periods/open-hints?admin_site_id='.$siteId)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.suggested_start', '')
|
||||||
|
->assertJsonPath('data.suggested_end', '')
|
||||||
|
->assertJsonFragment(['2026-06-01'])
|
||||||
|
->assertJsonFragment(['2026-06-30']);
|
||||||
|
});
|
||||||
@@ -40,6 +40,40 @@ test('cannot open duplicate settlement period for same range', function (): void
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cannot open settlement period overlapping a closed period', function (): void {
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$super = \App\Models\AdminUser::query()->create([
|
||||||
|
'username' => 'period_overlap_super',
|
||||||
|
'name' => 'Super',
|
||||||
|
'email' => null,
|
||||||
|
'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($super);
|
||||||
|
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
DB::table('settlement_periods')->insert([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => '2026-05-01 00:00:00',
|
||||||
|
'period_end' => '2026-05-31 23:59:59',
|
||||||
|
'status' => 'closed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson('/api/v1/admin/settlement-periods', [
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'period_start' => '2026-05-15 00:00:00',
|
||||||
|
'period_end' => '2026-06-15 23:59:59',
|
||||||
|
])
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonPath(
|
||||||
|
'data.errors.period_start.0',
|
||||||
|
trans('validation.business.period_overlaps_existing'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('cannot open second settlement period while another is open on same site', function (): void {
|
test('cannot open second settlement period while another is open on same site', function (): void {
|
||||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
$super = \App\Models\AdminUser::query()->create([
|
$super = \App\Models\AdminUser::query()->create([
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
use App\Models\AgentProfile;
|
use App\Models\AgentProfile;
|
||||||
use App\Support\AgentDefaultRolePermissions;
|
use App\Support\AgentDefaultRolePermissions;
|
||||||
|
|
||||||
test('base owner slugs include dashboard and settlement view but not wallet reconcile', function (): void {
|
test('base owner slugs include dashboard and settlement view but not wallet reconcile or platform reports', function (): void {
|
||||||
$slugs = AgentDefaultRolePermissions::baseSlugs();
|
$slugs = AgentDefaultRolePermissions::baseSlugs();
|
||||||
|
|
||||||
expect($slugs)
|
expect($slugs)
|
||||||
->toContain('prd.dashboard.view')
|
->toContain('prd.dashboard.view')
|
||||||
->toContain('prd.settlement.agent.view')
|
->toContain('prd.settlement.agent.view')
|
||||||
|
->not->toContain('prd.report.view')
|
||||||
->not->toContain('prd.wallet_reconcile.view')
|
->not->toContain('prd.wallet_reconcile.view')
|
||||||
->not->toContain('prd.wallet_reconcile.view_cs');
|
->not->toContain('prd.wallet_reconcile.view_cs');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user