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:
2026-06-12 15:59:05 +08:00
parent e14b7b4569
commit 980f3c9593
47 changed files with 2403 additions and 187 deletions

View File

@@ -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' => [],
];
}
}