feat: 增强报表功能,支持新参数和导出类型

- 在 `ReportJobStoreRequest` 中新增 `draw_id`、`draw_no` 和 `normalized_number` 参数的验证规则。
- 更新 `AdminReportJobService` 以支持动态生成输出路径后缀,确保导出文件名包含相关信息。
- 在 `AdminReportQueryService` 中新增多个报表类型的处理逻辑,包括 `draw_profit_summary` 和 `sold_out_number_report`。
- 添加相应的测试用例,确保新功能的正确性和稳定性。
This commit is contained in:
2026-05-26 11:48:40 +08:00
parent c74bec3f64
commit bba084b3c1
5 changed files with 464 additions and 1 deletions

View File

@@ -3,6 +3,14 @@
namespace App\Services\Admin;
use App\Models\AuditLog;
use App\Models\Draw;
use App\Models\RiskPool;
use App\Models\RiskPoolLockLog;
use App\Models\SettlementBatch;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Models\TransferOrder;
use App\Models\WalletTxn;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginatorInstance;
@@ -351,11 +359,16 @@ final class AdminReportQueryService
$dateTo = $range['date_to'];
return match ($reportType) {
'draw_profit_summary' => $this->drawProfitExportRows($filterJson),
'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo),
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo),
'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo),
'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo),
'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo),
'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo),
'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo),
'hot_number_risk_report' => $this->hotNumberRiskExportRows($filterJson),
'sold_out_number_report' => $this->soldOutNumberExportRows($filterJson),
default => [
['报表类型', '开始日期', '结束日期'],
[$this->reportLabel($reportType), $dateFrom, $dateTo],
@@ -363,6 +376,24 @@ final class AdminReportQueryService
};
}
public function resolveOutputPathSuffix(string $reportType, ?array $filterJson, string $dateFrom, string $dateTo): string
{
if (in_array($reportType, ['draw_profit_summary', 'hot_number_risk_report', 'sold_out_number_report'], true)) {
$draw = $this->resolveDrawForReport($filterJson);
if ($draw !== null) {
$suffix = (string) $draw->draw_no;
$number = $this->normalizedNumberFromFilters($filterJson);
if ($reportType === 'hot_number_risk_report' && $number !== null) {
return $suffix.'_'.$number;
}
return $suffix;
}
}
return $dateFrom.'_'.$dateTo;
}
public function reportLabel(string $reportType): string
{
return match ($reportType) {
@@ -566,6 +597,359 @@ final class AdminReportQueryService
return $query;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function drawProfitExportRows(?array $filterJson): array
{
$draw = $this->resolveDrawForReport($filterJson);
if ($draw === null) {
return [['提示', '请提供 draw_id 或 draw_no']];
}
$drawId = (int) $draw->id;
$totalBetMinor = (int) TicketOrder::query()->where('draw_id', $drawId)->sum('total_actual_deduct');
$orderCount = (int) TicketOrder::query()->where('draw_id', $drawId)->count();
$itemCount = (int) TicketItem::query()->where('draw_id', $drawId)->count();
$currencyCode = (string) (TicketOrder::query()->where('draw_id', $drawId)->value('currency_code') ?? '');
$totalWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('win_amount');
$totalJackpotWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('jackpot_win_amount');
$totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor;
$approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor;
$rows = [
[
'行类型', '期号', '状态', '币种', '订单数', '注单数', '下注', '派彩', '平台盈亏',
'结算批次ID', '结算状态', '批次数', '中奖数', '批次派彩', '批次Jackpot', '完成时间',
],
[
'summary',
$draw->draw_no,
$draw->status,
$currencyCode !== '' ? $currencyCode : null,
$orderCount,
$itemCount,
$totalBetMinor,
$totalPayoutMinor,
$approxHouseGrossMinor,
null,
null,
null,
null,
null,
null,
null,
],
];
$batches = SettlementBatch::query()
->where('draw_id', $drawId)
->orderByDesc('id')
->limit(100)
->get();
foreach ($batches as $batch) {
$rows[] = [
'settlement_batch',
$draw->draw_no,
$draw->status,
$currencyCode !== '' ? $currencyCode : null,
null,
null,
null,
null,
null,
(int) $batch->id,
$batch->status,
(int) $batch->total_ticket_count,
(int) $batch->total_win_count,
(int) $batch->total_payout_amount,
(int) $batch->total_jackpot_payout_amount,
$batch->finished_at?->toIso8601String(),
];
}
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function soldOutNumberExportRows(?array $filterJson): array
{
$draw = $this->resolveDrawForReport($filterJson);
if ($draw === null) {
return [['提示', '请提供 draw_id 或 draw_no']];
}
$rows = [
['期号', '号码', '封顶', '已占用', '剩余', '售罄', '使用率%'],
];
$pools = RiskPool::query()
->where('draw_id', $draw->id)
->where('sold_out_status', 1)
->orderBy('normalized_number')
->limit(10000)
->get();
foreach ($pools as $pool) {
$cap = (int) $pool->total_cap_amount;
$locked = (int) $pool->locked_amount;
$rows[] = [
$draw->draw_no,
$pool->normalized_number,
$cap,
$locked,
(int) $pool->remaining_amount,
'是',
$this->riskUsageRatioPercent($cap, $locked),
];
}
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function hotNumberRiskExportRows(?array $filterJson): array
{
$draw = $this->resolveDrawForReport($filterJson);
if ($draw === null) {
return [['提示', '请提供 draw_id 或 draw_no']];
}
$number = $this->normalizedNumberFromFilters($filterJson);
$rows = [
[
'行类型', '期号', '号码', '封顶', '已占用', '剩余', '售罄', '使用率%',
'日志ID', '动作', '金额', '玩法', '注单号', '玩家ID', '原因', '时间',
],
];
if ($number !== null) {
$pool = RiskPool::query()
->where('draw_id', $draw->id)
->where('normalized_number', $number)
->first();
if ($pool === null) {
$rows[] = ['pool', $draw->draw_no, $number, null, null, null, null, null, null, null, null, null, null, null, null, null];
return $rows;
}
$cap = (int) $pool->total_cap_amount;
$locked = (int) $pool->locked_amount;
$rows[] = [
'pool',
$draw->draw_no,
$pool->normalized_number,
$cap,
$locked,
(int) $pool->remaining_amount,
(int) $pool->sold_out_status === 1 ? '是' : '否',
$this->riskUsageRatioPercent($cap, $locked),
null,
null,
null,
null,
null,
null,
null,
null,
];
$logs = RiskPoolLockLog::query()
->where('draw_id', $draw->id)
->where('normalized_number', $number)
->with(['ticketItem:id,ticket_no,play_code,player_id'])
->orderByDesc('id')
->limit(5000)
->get();
foreach ($logs as $log) {
$rows[] = [
'lock_log',
$draw->draw_no,
$number,
null,
null,
null,
null,
null,
(int) $log->id,
$log->action_type,
(int) $log->amount,
$log->ticketItem?->play_code,
$log->ticketItem?->ticket_no,
$log->ticketItem?->player_id,
$log->source_reason,
$log->created_at?->toIso8601String(),
];
}
return $rows;
}
$pools = RiskPool::query()
->where('draw_id', $draw->id)
->orderByDesc('locked_amount')
->limit(10000)
->get();
foreach ($pools as $pool) {
$cap = (int) $pool->total_cap_amount;
$locked = (int) $pool->locked_amount;
$rows[] = [
'pool',
$draw->draw_no,
$pool->normalized_number,
$cap,
$locked,
(int) $pool->remaining_amount,
(int) $pool->sold_out_status === 1 ? '是' : '否',
$this->riskUsageRatioPercent($cap, $locked),
null,
null,
null,
null,
null,
null,
null,
null,
];
}
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function transferOrdersExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
{
$rows = [
['转账单号', '玩家ID', '用户名', '昵称', '方向', '币种', '金额', '状态', '外部单号', '失败原因', '创建时间', '完成时间'],
];
$query = TransferOrder::query()
->with(['player:id,username,nickname'])
->orderByDesc('id');
$playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null;
if ($playerId !== null && $playerId > 0) {
$query->where('player_id', $playerId);
}
$query->where('created_at', '>=', $dateFrom.' 00:00:00')
->where('created_at', '<=', $dateTo.' 23:59:59');
foreach ($query->limit(10000)->get() as $order) {
$player = $order->player;
$rows[] = [
$order->transfer_no,
(int) $order->player_id,
$player?->username,
$player?->nickname,
$order->direction,
$order->currency_code,
(int) $order->amount,
$order->status,
$order->external_ref_no,
$order->fail_reason,
$order->created_at?->toIso8601String(),
$order->finished_at?->toIso8601String(),
];
}
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function walletTxnsExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
{
$rows = [
['流水号', '玩家ID', '用户名', '业务类型', '业务单号', '方向', '金额', '变动前余额', '变动后余额', '状态', '外部单号', '备注', '创建时间'],
];
$query = WalletTxn::query()
->with(['player:id,username'])
->orderByDesc('id');
$playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null;
if ($playerId !== null && $playerId > 0) {
$query->where('player_id', $playerId);
}
$query->where('created_at', '>=', $dateFrom.' 00:00:00')
->where('created_at', '<=', $dateTo.' 23:59:59');
foreach ($query->limit(10000)->get() as $txn) {
$rows[] = [
$txn->txn_no,
(int) $txn->player_id,
$txn->player?->username,
$txn->biz_type,
$txn->biz_no,
(int) $txn->direction,
(int) $txn->amount,
(int) $txn->balance_before,
(int) $txn->balance_after,
$txn->status,
$txn->external_ref_no,
$txn->remark,
$txn->created_at?->toIso8601String(),
];
}
return $rows;
}
private function resolveDrawForReport(?array $filterJson): ?Draw
{
if (! is_array($filterJson)) {
return null;
}
if (! empty($filterJson['draw_id'])) {
return Draw::query()->find((int) $filterJson['draw_id']);
}
$drawNo = trim((string) ($filterJson['draw_no'] ?? ''));
if ($drawNo !== '') {
return Draw::query()->where('draw_no', $drawNo)->first();
}
return null;
}
private function normalizedNumberFromFilters(?array $filterJson): ?string
{
if (! is_array($filterJson)) {
return null;
}
$number = trim((string) ($filterJson['normalized_number'] ?? ''));
if (preg_match('/^[0-9]{4}$/', $number) === 1) {
return $number;
}
return null;
}
private function riskUsageRatioPercent(int $cap, int $locked): ?string
{
if ($cap <= 0) {
return null;
}
return (string) round($locked / $cap * 100, 2);
}
private function formatBusinessDateValue(mixed $value): ?string
{
if ($value === null) {