floatval($row->return_ratio ?? 0), 'unlock_ratio' => floatval($row->unlock_ratio ?? 0), 'points_to_cash_ratio' => floatval($row->points_to_cash_ratio ?? 0), 'daily_points_enabled' => intval($row->daily_points_enabled ?? 1) === 1, 'lifetime_points_enabled' => intval($row->lifetime_points_enabled ?? 0) === 1, 'lifetime_points_ratio' => floatval($row->lifetime_points_ratio ?? 0), 'mall_open_date' => self::normalizeDate($row->mall_open_date ?? null), 'mall_open_date_previous' => self::normalizeDate($row->mall_open_date_previous ?? null), 'mall_open_date_effective_on' => self::normalizeDate($row->mall_open_date_effective_on ?? null), 'active_mall_open_date' => self::resolveActiveMallOpenDate($row), 'mall_event_end_date' => self::normalizeDate($row->mall_event_end_date ?? null), 'mall_event_end_date_previous' => self::normalizeDate($row->mall_event_end_date_previous ?? null), 'mall_event_end_date_effective_on' => self::normalizeDate($row->mall_event_end_date_effective_on ?? null), 'active_event_end_date' => self::resolveActiveEventEndDate($row), ]; } public static function lossToPoints(float $winLossNet, float $ratio): int { if ($winLossNet >= 0 || $ratio <= 0) { return 0; } return self::lossIntegerToPoints($winLossNet, $ratio); } /** * 仅统计输钱:取净输赢绝对值的整数部分,再按比例折算为正整数积分 */ public static function lossIntegerToPoints(float $winLossNet, float $ratio): int { if ($winLossNet >= 0 || $ratio <= 0) { return 0; } $lossInteger = intval(floor(abs($winLossNet))); if ($lossInteger <= 0) { return 0; } return intval(round($lossInteger * $ratio)); } /** * 每日推送:昨日净输赢折算积分(受开关、商城开放日约束) * * @param array $config */ public static function dailyConvertedPoints( string $businessDate, float $yesterdayWinLossNet, array $config, ?string $settlementCalendarDate = null ): int { if (empty($config['daily_points_enabled'])) { return 0; } if (!self::isDailyPushSettlementAllowed($businessDate, $config, $settlementCalendarDate)) { return 0; } $ratio = floatval($config['return_ratio'] ?? 0); return self::lossIntegerToPoints($yesterdayWinLossNet, $ratio); } /** * 每日推送是否允许折算昨日净输赢、昨日充值解锁上限(活动开始/结束日规则) */ public static function isDailyPushSettlementAllowed( string $businessDate, array $config, ?string $settlementCalendarDate = null ): bool { $settlementDay = self::normalizeDate($settlementCalendarDate ?? date('Y-m-d')) ?? date('Y-m-d'); $start = self::normalizeDate($config['active_mall_open_date'] ?? null); if ($start !== null && $businessDate < $start) { return false; } $end = self::normalizeDate($config['active_event_end_date'] ?? null); if ($end === null) { return true; } if ($businessDate > $end) { return false; } $endPlusOne = self::nextCalendarDate($end); if ($settlementDay === $endPlusOne && $businessDate >= $end) { return false; } if ($settlementDay > $endPlusOne) { return false; } return true; } /** * 活动开放期内按推送累加净输赢;关闭期间不累加,再开启后接着累加(不重置)。 * * @param float|null $previousWinLossNet 更新已有每日推送时传旧「昨日净输赢」;新建推送传 null */ public static function applyEventPeriodWinLossForDailyPush( MallUserAsset $asset, string $businessDate, float $yesterdayWinLossNet, array $config, ?string $settlementCalendarDate = null, ?float $previousWinLossNet = null ): void { if (!self::isDailyPushSettlementAllowed($businessDate, $config, $settlementCalendarDate)) { return; } $current = floatval($asset->event_period_win_loss_net ?? 0); if ($previousWinLossNet === null) { $asset->event_period_win_loss_net = round($current + $yesterdayWinLossNet, 2); return; } $delta = $yesterdayWinLossNet - $previousWinLossNet; if ($delta === 0.0) { return; } $asset->event_period_win_loss_net = round($current + $delta, 2); } public static function dailyPushTodayLimit( string $businessDate, float $yesterdayTotalDeposit, array $config, ?string $settlementCalendarDate = null ): int { if (!self::isDailyPushSettlementAllowed($businessDate, $config, $settlementCalendarDate)) { return 0; } $unlockRatio = floatval($config['unlock_ratio'] ?? 0); return intval(round($yesterdayTotalDeposit * $unlockRatio)); } /** * @param array $config * @deprecated 使用 dailyConvertedPoints */ public static function dailyLockedIncrement(string $businessDate, float $yesterdayWinLossNet, array $config): int { return self::dailyConvertedPoints($businessDate, $yesterdayWinLossNet, $config); } /** * @return array */ public static function configForConversion(): array { return self::configFromRow(MallBusinessConfig::ensureRow()); } public static function syncClaimCap(MallUserAsset $asset): void { $lifetime = max(0, intval($asset->lifetime_converted_points ?? 0)); $daily = max(0, intval($asset->daily_converted_points ?? 0)); $asset->points_claim_cap = $lifetime + $daily; } public static function applyDailyConversionDelta(MallUserAsset $asset, int $delta): void { if ($delta === 0) { return; } $asset->daily_converted_points = max(0, intval($asset->daily_converted_points ?? 0) + $delta); self::syncClaimCap($asset); $newLocked = intval($asset->locked_points ?? 0) + $delta; $asset->locked_points = max(0, $newLocked); } public static function applyLifetimeConversion(MallUserAsset $asset, int $targetLifetimePoints): void { $current = max(0, intval($asset->lifetime_converted_points ?? 0)); $delta = $targetLifetimePoints - $current; if ($delta === 0) { return; } $asset->lifetime_converted_points = $targetLifetimePoints; self::syncClaimCap($asset); $newLocked = intval($asset->locked_points ?? 0) + $delta; $asset->locked_points = max(0, $newLocked); } /** * @return array{processed: int, updated: int, skipped: int} */ public static function recalculateAllLifetimePoints(MallBusinessConfig $row): array { if (intval($row->lifetime_points_enabled ?? 0) !== 1) { throw new \RuntimeException('Lifetime points source is disabled'); } $config = self::configFromRow($row); $ratio = floatval($config['lifetime_points_ratio'] ?? 0); if ($ratio < 0) { throw new \RuntimeException('Parameter error'); } $stats = ['processed' => 0, 'updated' => 0, 'skipped' => 0]; $rows = Db::query( 'SELECT d.* FROM mall_daily_push d INNER JOIN ( SELECT user_id, MAX(`date`) AS max_date FROM mall_daily_push GROUP BY user_id ) latest ON d.user_id = latest.user_id AND d.date = latest.max_date' ); foreach ($rows as $pushRow) { $stats['processed']++; $playxUserId = trim(strval($pushRow['user_id'] ?? '')); if ($playxUserId === '') { $stats['skipped']++; continue; } $lifetimeWl = $pushRow['lifetime_win_loss_net'] ?? null; if ($lifetimeWl === null || $lifetimeWl === '') { $stats['skipped']++; continue; } $target = self::lossToPoints(floatval($lifetimeWl), $ratio); $asset = MallUserAsset::where('playx_user_id', $playxUserId)->lock(true)->find(); if (!$asset) { $stats['skipped']++; continue; } $before = intval($asset->lifetime_converted_points ?? 0); if ($before === $target) { $stats['skipped']++; continue; } self::applyLifetimeConversion($asset, $target); $asset->save(); $stats['updated']++; } return $stats; } public static function remainingClaimable(MallUserAsset $asset): int { $cap = max(0, intval($asset->points_claim_cap ?? 0)); $claimed = max(0, intval($asset->total_points_claimed ?? 0)); return max(0, $cap - $claimed); } public static function resolveActiveMallOpenDate(MallBusinessConfig $row): ?string { $pending = self::normalizeDate($row->mall_open_date ?? null); $previous = self::normalizeDate($row->mall_open_date_previous ?? null); $effectiveOn = self::normalizeDate($row->mall_open_date_effective_on ?? null); $today = date('Y-m-d'); if ($pending === null) { return $previous; } if ($effectiveOn === null || $today >= $effectiveOn) { return $pending; } return $previous; } public static function resolveActiveEventEndDate(MallBusinessConfig $row): ?string { $pending = self::normalizeDate($row->mall_event_end_date ?? null); $previous = self::normalizeDate($row->mall_event_end_date_previous ?? null); $effectiveOn = self::normalizeDate($row->mall_event_end_date_effective_on ?? null); $today = date('Y-m-d'); if ($pending === null) { if ($effectiveOn !== null && $today >= $effectiveOn) { return null; } return $previous; } if ($effectiveOn === null || $today >= $effectiveOn) { return $pending; } return $previous; } public static function normalizeDate(mixed $value): ?string { if ($value === null || $value === '') { return null; } $str = trim(strval($value)); if ($str === '') { return null; } if (strlen($str) >= 10) { $str = substr($str, 0, 10); } $parsed = \DateTime::createFromFormat('Y-m-d', $str); return $parsed && $parsed->format('Y-m-d') === $str ? $str : null; } public static function nextCalendarDate(string $fromDate = ''): string { $base = $fromDate !== '' ? $fromDate : date('Y-m-d'); $dt = \DateTime::createFromFormat('Y-m-d', $base); if (!$dt) { $dt = new \DateTime(); } $dt->modify('+1 day'); return $dt->format('Y-m-d'); } public static function parseLifetimeWinLossFromMember(array $member): ?float { if (!array_key_exists('ltv_wl', $member) && !array_key_exists('lifetime_total_wl', $member) && !array_key_exists('ltv_total_wl', $member)) { return null; } $raw = $member['ltv_wl'] ?? ($member['lifetime_total_wl'] ?? ($member['ltv_total_wl'] ?? null)); if ($raw === null || $raw === '') { return null; } if (!is_numeric($raw)) { return null; } return floatval($raw); } }