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:
@@ -6,7 +6,7 @@ use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
/** 代理账单按管理员可访问站点 + 代理子树过滤。 */
|
||||
/** 结算中心账单:站点范围 + 绑定代理仅见直属边(玩家↔直属代理、代理↔直接上下级)。 */
|
||||
final class AdminAgentSettlementScope
|
||||
{
|
||||
/**
|
||||
@@ -32,6 +32,17 @@ final class AdminAgentSettlementScope
|
||||
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
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
@@ -52,7 +63,7 @@ final class AdminAgentSettlementScope
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds === null) {
|
||||
self::applySubtreeToBillsQuery($query, $admin, $billsAlias);
|
||||
self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -70,35 +81,72 @@ final class AdminAgentSettlementScope
|
||||
->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
|
||||
{
|
||||
$subtreeIds = self::subtreeAgentNodeIds($admin);
|
||||
if ($subtreeIds === null) {
|
||||
self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定代理:
|
||||
* - 玩家账单:仅直属玩家(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;
|
||||
}
|
||||
|
||||
if ($subtreeIds === []) {
|
||||
$actorId = self::boundAgentNodeId($admin);
|
||||
if ($actorId === null) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function (Builder $outer) use ($billsAlias, $subtreeIds): void {
|
||||
$outer->where(function (Builder $player) use ($billsAlias, $subtreeIds): void {
|
||||
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
|
||||
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')
|
||||
->whereExists(function (Builder $exists) use ($billsAlias, $subtreeIds): void {
|
||||
->whereExists(function (Builder $exists) use ($billsAlias, $actorId): void {
|
||||
$exists->selectRaw('1')
|
||||
->from('players')
|
||||
->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')
|
||||
->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
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
@@ -157,7 +218,13 @@ final class AdminAgentSettlementScope
|
||||
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills as sb')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->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();
|
||||
|
||||
if ($bill === null) {
|
||||
@@ -168,23 +235,127 @@ final class AdminAgentSettlementScope
|
||||
return false;
|
||||
}
|
||||
|
||||
$subtreeIds = self::subtreeAgentNodeIds($admin);
|
||||
if ($subtreeIds === null) {
|
||||
$actorId = self::boundAgentNodeId($admin);
|
||||
if ($actorId === null) {
|
||||
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') {
|
||||
$agentNodeId = (int) (\Illuminate\Support\Facades\DB::table('players')
|
||||
->where('id', (int) $bill->owner_id)
|
||||
->value('agent_node_id') ?? 0);
|
||||
|
||||
return $agentNodeId > 0 && in_array($agentNodeId, $subtreeIds, true);
|
||||
return $agentNodeId === $actorId;
|
||||
}
|
||||
|
||||
if ((string) $bill->owner_type === 'agent') {
|
||||
return in_array((int) $bill->owner_id, $subtreeIds, true);
|
||||
return self::agentBillOnDirectEdge($actorId, $bill);
|
||||
}
|
||||
|
||||
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{
|
||||
* id: int,
|
||||
* admin_site_id: int,
|
||||
* admin_site_name: string,
|
||||
* site_code: string,
|
||||
* path: string,
|
||||
* code: string,
|
||||
@@ -73,6 +74,7 @@ final class AdminAuthProfile
|
||||
* @return array{
|
||||
* id: int,
|
||||
* admin_site_id: int,
|
||||
* admin_site_name: string,
|
||||
* site_code: string,
|
||||
* path: string,
|
||||
* code: string,
|
||||
@@ -93,13 +95,18 @@ final class AdminAuthProfile
|
||||
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();
|
||||
|
||||
return [
|
||||
'id' => (int) $node->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,
|
||||
'code' => (string) $node->code,
|
||||
'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.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.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.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']],
|
||||
|
||||
@@ -21,7 +21,6 @@ final class AgentDefaultRolePermissions
|
||||
'prd.agent.role.view',
|
||||
'prd.agent.user.view',
|
||||
'prd.tickets.view',
|
||||
'prd.report.view',
|
||||
'prd.settlement.agent.view',
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
|
||||
/**
|
||||
@@ -36,13 +37,25 @@ final class AgentProfileCapabilityFilter
|
||||
'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)。
|
||||
*
|
||||
* @param list<string> $permissionCodes
|
||||
* @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 = [];
|
||||
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);
|
||||
sort($out);
|
||||
|
||||
@@ -81,9 +98,12 @@ final class AgentProfileCapabilityFilter
|
||||
* @param list<string> $permissionCodes
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function filterMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
|
||||
{
|
||||
return self::applyToMenuActionCodes($permissionCodes, $profile);
|
||||
public static function filterMenuActionCodes(
|
||||
array $permissionCodes,
|
||||
?AgentProfile $profile,
|
||||
?AgentNode $node = null,
|
||||
): array {
|
||||
return self::applyToMenuActionCodes($permissionCodes, $profile, $node);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
namespace App\Support;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** 账期起止统一为日界(startOfDay / endOfDay),供聚合、流水、关账回填共用。 */
|
||||
/** 账期起止边界:开账时规范化写入,关账/聚合/流水筛选共用同一对 UTC 时刻。 */
|
||||
final class AgentSettlementPeriodWindow
|
||||
{
|
||||
/**
|
||||
@@ -13,8 +14,8 @@ final class AgentSettlementPeriodWindow
|
||||
public static function bounds(string $periodStart, string $periodEnd): array
|
||||
{
|
||||
return [
|
||||
Carbon::parse($periodStart)->startOfDay(),
|
||||
Carbon::parse($periodEnd)->endOfDay(),
|
||||
Carbon::parse($periodStart)->utc(),
|
||||
Carbon::parse($periodEnd)->utc(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,4 +28,42 @@ final class AgentSettlementPeriodWindow
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user