Files
lotteryLaravel/app/Services/AgentSettlement/SettlementPeriodOpenHintsService.php
kang d4779660c8
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
feat(draw): 支持多玩法provider及本地化结算时区功能
- Draw相关控制器添加provider_code和provider_name字段支持
- 允许手动录入开奖批次时指定provider_code
- RNG开奖批次生成支持多provider,分别生成对应批次数据
- DrawPublishService和DrawManualResultService中支持基于provider_code管理开奖版本和发布流程
- DrawResultViewService调整,支持按provider汇总及筛选开奖结果
- Settlement处理逻辑调整,按provider区分待结算票据及批次,分开结算
- 结算期间管理相关服务支持使用站点本地结算时区计算开账建议和日期处理
- 增加AdminSite的settlement_timezone字段及相关请求验证和数据存取支持
- 优化AdminSettlementPeriod相关代码,防止存在未结清票据时关账
- Ticket明细返回接口添加结算时区信息,提升用户时间体验
- 多处查询排序和版本号处理改进,保证多provider数据正确顺序与一致性
2026-07-09 15:48:21 +08:00

222 lines
8.3 KiB
PHP

<?php
namespace App\Services\AgentSettlement;
use Carbon\Carbon;
use App\Support\AgentSettlementSiteTimezone;
use Illuminate\Support\Facades\DB;
/** 开账弹窗:建议账期与日历标记(已有账期 / 待入账 / 未结清 / 已关账漏账)。 */
final class SettlementPeriodOpenHintsService
{
/**
* @return array{
* suggested_start: string,
* suggested_end: string,
* settlement_timezone: string,
* occupied_period_dates: list<string>,
* pending_activity_dates: list<string>,
* orphan_activity_dates: list<string>,
* unpaid_bill_dates: list<string>
* }
*/
public function hints(int $adminSiteId): array
{
$site = DB::table('admin_sites')->where('id', $adminSiteId)->first(['code', 'settlement_timezone']);
$siteCode = (string) ($site->code ?? '');
if ($siteCode === '') {
return $this->emptyHints();
}
$timezone = AgentSettlementSiteTimezone::isValid((string) ($site->settlement_timezone ?? ''))
? (string) $site->settlement_timezone
: AgentSettlementSiteTimezone::forSiteId($adminSiteId);
$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->expandPeriodToLocalDays((string) $row->period_start, (string) $row->period_end, $timezone) as $day) {
$occupiedPeriodDates[$day] = true;
}
}
$lastPeriod = DB::table('settlement_periods')
->where('admin_site_id', $adminSiteId)
->whereIn('status', ['closed', 'completed'])
->orderByDesc('period_end')
->first();
$pendingLedgerRows = 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')
->orderBy('sl.settled_at')
->get(['sl.settled_at']);
$pendingActivityDates = [];
$orphanActivityDates = [];
foreach ($pendingLedgerRows as $row) {
$day = AgentSettlementSiteTimezone::localDayFromUtc($row->settled_at, $timezone);
$pendingActivityDates[$day] = true;
if (isset($occupiedPeriodDates[$day])) {
$orphanActivityDates[$day] = true;
}
}
$pendingActivityDates = array_keys($pendingActivityDates);
$orphanActivityDates = array_keys($orphanActivityDates);
$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->expandPeriodToLocalDays((string) $row->period_start, (string) $row->period_end, $timezone) as $day) {
$unpaidBillDates[$day] = true;
}
}
$suggested = $this->suggestRange($lastPeriod, $pendingActivityDates, $occupiedPeriodDates, $timezone);
return [
'suggested_start' => $suggested['start'],
'suggested_end' => $suggested['end'],
'settlement_timezone' => $timezone,
'occupied_period_dates' => array_keys($occupiedPeriodDates),
'pending_activity_dates' => $pendingActivityDates,
'orphan_activity_dates' => $orphanActivityDates,
'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,
string $timezone,
): array
{
$lastEndDay = $lastPeriod !== null
? Carbon::parse((string) $lastPeriod->period_end, 'UTC')->timezone($timezone)->startOfDay()
: null;
$freePending = array_values(array_filter(
$pendingActivityDates,
static fn (string $day): bool => ! isset($occupiedPeriodDates[$day]),
));
if ($freePending !== []) {
$minDay = Carbon::parse($freePending[0])->startOfDay();
$maxDay = Carbon::parse($freePending[array_key_last($freePending)])->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($timezone)->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 expandPeriodToLocalDays(string $periodStart, string $periodEnd, string $timezone): array
{
$dates = [];
$cursor = Carbon::parse($periodStart, 'UTC')->timezone($timezone)->startOfDay();
$end = Carbon::parse($periodEnd, 'UTC')->timezone($timezone)->startOfDay();
while ($cursor->lessThanOrEqualTo($end)) {
$dates[] = $cursor->format('Y-m-d');
$cursor->addDay();
}
return $dates;
}
/** @return array{suggested_start: string, suggested_end: string, settlement_timezone: string, occupied_period_dates: list<string>, pending_activity_dates: list<string>, orphan_activity_dates: list<string>, unpaid_bill_dates: list<string>} */
private function emptyHints(): array
{
return [
'suggested_start' => '',
'suggested_end' => '',
'settlement_timezone' => (string) config('lottery.settlement.default_timezone', 'UTC'),
'occupied_period_dates' => [],
'pending_activity_dates' => [],
'orphan_activity_dates' => [],
'unpaid_bill_dates' => [],
];
}
}