*/ public const PLATFORMS = [ ['slug' => 'taobao-alipay', 'name' => '淘系支付宝账户'], ['slug' => 'taobao-wechat', 'name' => '淘系微信账户'], ['slug' => 'pinduoduo', 'name' => '拼多多账户'], ['slug' => 'jd-wallet', 'name' => '京东网银钱包账户'], ['slug' => 'douyin', 'name' => '抖音账户'], ['slug' => 'vipshop', 'name' => '唯品会MP账户'], ['slug' => 'vipshop-jx', 'name' => '唯品会JX账户'], ['slug' => 'xiaohongshu', 'name' => '小红书账户'], ]; public const MONTHLY_CLOSING_MONTHS = [ ['key' => '2026-07', 'label' => '7月'], ['key' => '2026-08', 'label' => '8月'], ['key' => '2026-09', 'label' => '9月'], ['key' => '2026-10', 'label' => '10月'], ]; public function __construct( private readonly AlipayBills $alipayBills, private readonly TaobaoWechatAccountBills $taobaoWechatBills, private readonly PinduoduoAccountBills $pinduoduoBills, private readonly AccountBills $accountBills, ) {} /** @return array */ public function overview(string $periodStart, ?string $periodEnd = null): array { [$periodStart, $periodEnd, $periodMonth] = $this->periodForRange($periodStart, $periodEnd); $platforms = []; $availableTotals = [ 'opening_balance' => 0.0, 'income' => 0.0, 'expense' => 0.0, ]; $availableCount = 0; foreach (self::PLATFORMS as $definition) { try { $summary = $this->fetchPlatformSummary( $definition['slug'], $periodStart, $periodEnd, ); if ($summary['opening_balance'] === null) { throw new RuntimeException('平台期初余额未就绪'); } $availableCount++; $availableTotals['opening_balance'] += (float) $summary['opening_balance']; $availableTotals['income'] += (float) $summary['income']; $availableTotals['expense'] += (float) $summary['expense']; $platforms[] = [ 'slug' => $definition['slug'], 'name' => $definition['name'], 'available' => true, 'opening_balance' => round((float) $summary['opening_balance'], 2), 'income' => round((float) $summary['income'], 2), 'expense' => round((float) $summary['expense'], 2), 'balance' => round( (float) $summary['opening_balance'] + (float) $summary['income'] - (float) $summary['expense'], 2, ), 'error' => null, ]; } catch (Throwable $exception) { if ($exception->getMessage() !== '平台期初余额未就绪') { report($exception); } $platforms[] = [ 'slug' => $definition['slug'], 'name' => $definition['name'], 'available' => false, 'opening_balance' => null, 'income' => null, 'expense' => null, 'balance' => null, 'error' => $this->safeErrorMessage($exception), ]; } } $complete = $availableCount === count(self::PLATFORMS); $openingBalance = $complete ? round($availableTotals['opening_balance'], 2) : null; $income = $complete ? round($availableTotals['income'], 2) : null; $expense = $complete ? round($availableTotals['expense'], 2) : null; return [ 'month' => $periodMonth, 'period' => [ 'start' => $periodStart, 'end' => $periodEnd, ], 'summary' => [ 'opening_balance' => $openingBalance, 'income' => $income, 'expense' => $expense, 'balance' => $complete ? round($openingBalance + $income - $expense, 2) : null, 'platform_count' => count(self::PLATFORMS), 'available_platform_count' => $availableCount, 'complete' => $complete, ], 'platforms' => $platforms, ]; } /** * 2026 年 7-10 月各平台期末余额矩阵。环比为 10 月相对 9 月。 * * @return array */ public function monthlyClosing(): array { $monthOverviews = []; foreach (self::MONTHLY_CLOSING_MONTHS as $month) { $monthOverviews[$month['key']] = $this->overview($month['key']); } $rows = []; $availableRows = 0; foreach (self::PLATFORMS as $definition) { $balances = []; foreach (self::MONTHLY_CLOSING_MONTHS as $month) { $platform = $this->platformFromOverview( $monthOverviews[$month['key']], $definition['slug'], ); $balances[$month['key']] = $platform !== null && $platform['available'] && is_numeric($platform['balance'] ?? null) ? round((float) $platform['balance'], 2) : null; } $available = ! in_array(null, $balances, true); if ($available) { $availableRows++; } $rows[] = $this->monthlyClosingRow( $definition['slug'], $definition['name'], $balances, $available, ); } $totals = []; foreach (self::MONTHLY_CLOSING_MONTHS as $month) { $overview = $monthOverviews[$month['key']]; $totals[$month['key']] = $overview['summary']['complete'] && is_numeric($overview['summary']['balance'] ?? null) ? round((float) $overview['summary']['balance'], 2) : null; } return [ 'months' => self::MONTHLY_CLOSING_MONTHS, 'comparison' => [ 'current' => '2026-10', 'previous' => '2026-09', ], 'summary' => [ 'platform_count' => count(self::PLATFORMS), 'available_platform_count' => $availableRows, 'complete' => $availableRows === count(self::PLATFORMS) && ! in_array(null, $totals, true), ], 'total' => $this->monthlyClosingRow( 'total', '合计', $totals, ! in_array(null, $totals, true), ), 'platforms' => $rows, ]; } /** @param array $overview */ private function platformFromOverview(array $overview, string $slug): ?array { foreach ($overview['platforms'] ?? [] as $platform) { if (($platform['slug'] ?? null) === $slug) { return $platform; } } return null; } /** * @param array $balances * @return array */ private function monthlyClosingRow( string $slug, string $name, array $balances, bool $available, ): array { $currentMonth = (string) self::MONTHLY_CLOSING_MONTHS[array_key_last(self::MONTHLY_CLOSING_MONTHS)]['key']; $previousMonth = (string) self::MONTHLY_CLOSING_MONTHS[array_key_last(self::MONTHLY_CLOSING_MONTHS) - 1]['key']; $current = $balances[$currentMonth]; $previous = $balances[$previousMonth]; $changeAmount = $current !== null && $previous !== null ? round($current - $previous, 2) : null; $changeRate = $changeAmount !== null && abs($previous) >= 0.005 ? round($changeAmount / abs($previous) * 100, 2) : ($changeAmount === 0.0 ? 0.0 : null); return [ 'slug' => $slug, 'name' => $name, 'available' => $available, 'balances' => $balances, 'change' => [ 'amount' => $changeAmount, 'rate' => $changeRate, ], ]; } /** * 收入合计的平台、店铺、账单项目明细。 * * 各平台仍沿用对应账单页的同一数据源和账期口径;这里只汇总收入行, * 明细金额统一返回元,页面再按万元展示。 * * @return array */ public function incomeDetail(string $periodStart, ?string $periodEnd = null): array { return $this->flowDetail($periodStart, $periodEnd, 'income'); } /** * 支出合计的平台、店铺、账单项目明细。 * * 支出明细沿用各平台账单页的同一数据源和账期口径,只汇总支出行; * 明细金额统一返回元,页面再按万元展示。 * * @return array */ public function expenseDetail(string $periodStart, ?string $periodEnd = null): array { return $this->flowDetail($periodStart, $periodEnd, 'expense'); } /** * 期末余额合计的平台明细。 * * 直接复用总览的平台余额,确保明细合计与期末余额卡片口径一致。 * 明细金额统一返回元,页面再按万元展示。 * * @return array */ public function balanceDetail(string $periodStart, ?string $periodEnd = null): array { $overview = $this->overview($periodStart, $periodEnd); $details = []; foreach ($overview['platforms'] as $platform) { if (! $platform['available'] || ! is_numeric($platform['balance'] ?? null)) { continue; } $details[] = [ 'platform_slug' => $platform['slug'], 'platform' => $platform['name'], 'store_name' => '', 'bill_item' => '', 'amount' => round((float) $platform['balance'], 2), ]; } return [ 'month' => $overview['month'], 'period' => $overview['period'], 'summary' => [ 'balance' => $overview['summary']['balance'], 'platform_count' => $overview['summary']['platform_count'], 'available_platform_count' => $overview['summary']['available_platform_count'], 'complete' => $overview['summary']['complete'], ], 'details' => $details, ]; } /** * 期末余额的店铺明细。 * * 每个店铺统一按「期初余额 + 收入 - 支出」计算;某一店铺缺少期初时 * 返回 null,由页面显示为“—”,不把未知期初静默当成 0。 * * @return array */ public function balanceStoreDetail(string $periodStart, ?string $periodEnd = null): array { [$periodStart, $periodEnd, $periodMonth] = $this->periodForRange($periodStart, $periodEnd); $details = []; $platformCount = 0; $storeCount = 0; $readyStoreCount = 0; $balance = 0.0; foreach (self::PLATFORMS as $index => $definition) { try { $rows = $this->fetchPlatformStoreBalances( $definition['slug'], $periodStart, $periodEnd, ); if ($rows === []) { continue; } $platformCount++; } catch (Throwable $exception) { throw new RuntimeException('店铺期末余额明细暂时无法读取,请稍后重试。', 0, $exception); } $platformRows = []; foreach ($rows as $row) { $storeName = trim((string) ($row['store_name'] ?? '')); $storeName = $storeName !== '' ? $storeName : '未标注店铺'; $opening = is_numeric($row['opening_balance'] ?? null) ? round((float) $row['opening_balance'], 2) : null; $income = round((float) ($row['income'] ?? 0), 2); $expense = round((float) ($row['expense'] ?? 0), 2); $amount = $opening === null ? null : round($opening + $income - $expense, 2); $storeCount++; if ($amount !== null) { $readyStoreCount++; $balance += $amount; } $platformRows[] = [ 'platform_slug' => $definition['slug'], 'platform' => $definition['name'], 'store_name' => $storeName, 'bill_item' => '', 'opening_balance' => $opening, 'income' => $income, 'expense' => $expense, 'amount' => $amount, 'available' => $amount !== null, '_platform_order' => $index, ]; } usort($platformRows, static function (array $left, array $right): int { return [ $left['amount'] === null ? 1 : 0, $left['amount'] === null ? 0 : -$left['amount'], $left['store_name'], ] <=> [ $right['amount'] === null ? 1 : 0, $right['amount'] === null ? 0 : -$right['amount'], $right['store_name'], ]; }); array_push($details, ...$platformRows); } foreach ($details as &$detail) { unset($detail['_platform_order']); } unset($detail); $complete = $platformCount === count(self::PLATFORMS) && $storeCount > 0 && $readyStoreCount === $storeCount; return [ 'month' => $periodMonth, 'period' => [ 'start' => $periodStart, 'end' => $periodEnd, ], 'summary' => [ 'balance' => $complete ? round($balance, 2) : null, 'platform_count' => count(self::PLATFORMS), 'available_platform_count' => $platformCount, 'store_count' => $storeCount, 'ready_store_count' => $readyStoreCount, 'complete' => $complete, ], 'details' => $details, ]; } /** @return array */ private function flowDetail(string $periodStart, ?string $periodEnd, string $flow): array { [$periodStart, $periodEnd, $periodMonth] = $this->periodForRange($periodStart, $periodEnd); $platformOrder = []; $details = []; $totalAmount = 0.0; foreach (self::PLATFORMS as $index => $definition) { $platformOrder[$definition['slug']] = $index; $buckets = []; try { $rows = $flow === 'income' ? $this->fetchPlatformIncomeRows( $definition['slug'], $periodStart, $periodEnd, ) : $this->fetchPlatformExpenseRows( $definition['slug'], $periodStart, $periodEnd, ); foreach ($rows as $row) { $amount = round((float) ($row[$flow] ?? 0), 2); if ($amount <= 0) { continue; } $storeName = trim((string) ($row['store_name'] ?? '')); $billItem = trim((string) ($row['bill_item'] ?? '')); $storeName = $storeName !== '' ? $storeName : '未标注店铺'; $billItem = $billItem !== '' ? $billItem : '未标注账单项目'; $buckets[$storeName][$billItem] = round( (float) ($buckets[$storeName][$billItem] ?? 0) + $amount, 2, ); $totalAmount += $amount; } } catch (Throwable $exception) { $label = $flow === 'income' ? '收入' : '支出'; throw new RuntimeException($label . '明细暂时无法读取,请稍后重试。', 0, $exception); } foreach ($buckets as $storeName => $items) { foreach ($items as $billItem => $amount) { $details[] = [ 'platform_slug' => $definition['slug'], 'platform' => $definition['name'], 'store_name' => (string) $storeName, 'bill_item' => (string) $billItem, 'amount' => round((float) $amount, 2), ]; } } } usort($details, static function (array $left, array $right) use ($platformOrder): int { return [ $platformOrder[$left['platform_slug']] ?? PHP_INT_MAX, -$left['amount'], $left['store_name'], $left['bill_item'], ] <=> [ $platformOrder[$right['platform_slug']] ?? PHP_INT_MAX, -$right['amount'], $right['store_name'], $right['bill_item'], ]; }); return [ 'month' => $periodMonth, 'period' => [ 'start' => $periodStart, 'end' => $periodEnd, ], 'summary' => [ $flow => round($totalAmount, 2), 'platform_count' => count(self::PLATFORMS), 'available_platform_count' => count(self::PLATFORMS), 'complete' => true, ], 'details' => $details, ]; } /** @return array{0:string,1:string,2:string} */ private function periodForRange(string $start, ?string $end): array { if ($end === null && preg_match('/^\d{4}-\d{2}$/', $start) === 1) { $month = CarbonImmutable::createFromFormat('!Y-m', $start, 'Asia/Shanghai'); if ($month instanceof CarbonImmutable && $month->format('Y-m') === $start) { return [ $month->startOfMonth()->format('Y-m-d'), $month->endOfMonth()->format('Y-m-d'), $start, ]; } } try { $periodStart = CarbonImmutable::createFromFormat('!Y-m-d', $start, 'Asia/Shanghai'); $periodEnd = $end !== null ? CarbonImmutable::createFromFormat('!Y-m-d', $end, 'Asia/Shanghai') : null; } catch (Throwable) { throw new RuntimeException('invalid_balance_overview_period'); } if (! $periodStart instanceof CarbonImmutable || $periodStart->format('Y-m-d') !== $start || ! $periodEnd instanceof CarbonImmutable || $periodEnd->format('Y-m-d') !== (string) $end || $periodEnd->lt($periodStart) ) { throw new RuntimeException('invalid_balance_overview_period'); } return [ $periodStart->format('Y-m-d'), $periodEnd->format('Y-m-d'), $periodStart->format('Y-m'), ]; } /** * 返回某平台所选月份的收入行。各仓库的导出游标复用账单页现有筛选口径, * 避免为明细页复制一套容易漂移的查询。 * * @return iterable */ protected function fetchPlatformIncomeRows(string $platform, string $start, string $end): iterable { return $this->fetchPlatformRows($platform, $start, $end, 'income'); } /** * 返回某平台所选月份的支出行。 * * @return iterable> */ protected function fetchPlatformExpenseRows(string $platform, string $start, string $end): iterable { return $this->fetchPlatformRows($platform, $start, $end, 'expense'); } /** * @return array */ protected function fetchPlatformStoreBalances(string $platform, string $start, string $end): array { return match ($platform) { 'taobao-alipay' => $this->taobaoAlipayStoreBalances($start, $end), 'taobao-wechat' => $this->taobaoWechatStoreBalances($start, $end), 'pinduoduo' => $this->pinduoduoStoreBalances($start, $end), 'jd-wallet' => $this->jdWalletStoreBalances($start, $end), 'douyin' => $this->douyinStoreBalances($start, $end), 'vipshop' => $this->vipshopStoreBalances($start, $end), 'vipshop-jx' => $this->vipshopJxStoreBalances($start, $end), 'xiaohongshu' => $this->xiaohongshuStoreBalances($start, $end), default => throw new RuntimeException('unsupported_balance_overview_platform'), }; } /** * @return array */ private function taobaoAlipayStoreBalances(string $start, string $end): array { $connection = $this->mirrorConnection(); $activity = $this->groupedActivity( $connection->table(self::TAOBAO_ALIPAY_ROW_TABLE) ->where('occurred_at', '>=', $start . ' 00:00:00') ->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00'), ); $opening = app(TaobaoAlipaySettlementBillRepository::class) ->storeOpeningBalances($start); return $this->mergeStoreBalances($opening, $activity); } /** * @return array */ private function taobaoWechatStoreBalances(string $start, string $end): array { $connection = $this->mirrorConnection(); $activity = $this->groupedActivity( $connection->table(self::TAOBAO_WECHAT_ROW_TABLE) ->where('occurred_at', '>=', $start . ' 00:00:00') ->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00'), ); $opening = []; if ($connection->getSchemaBuilder()->hasTable(self::TAOBAO_WECHAT_OPENING_TABLE)) { $baseline = $connection->table(self::TAOBAO_WECHAT_OPENING_TABLE) ->where('balance_date', TaobaoWechatOpeningBalanceRepository::BASELINE_DATE) ->select('store_name') ->selectRaw('COALESCE(SUM(balance), 0) AS balance') ->groupBy('store_name') ->get(); foreach ($baseline as $row) { $opening[$this->storeName($row->store_name)] = round((float) $row->balance, 2); } if (CarbonImmutable::parse($start)->gt(CarbonImmutable::parse('2026-07-01'))) { $movement = $this->groupedActivity( $connection->table(self::TAOBAO_WECHAT_ROW_TABLE) ->where('occurred_at', '>=', '2026-07-01 00:00:00') ->where('occurred_at', '<', $start . ' 00:00:00'), ); foreach ($opening as $storeName => $balance) { $row = $movement[$storeName] ?? ['income' => 0.0, 'expense' => 0.0]; $opening[$storeName] = round( $balance + (float) $row['income'] - (float) $row['expense'], 2, ); } } } return $this->mergeStoreBalances($opening, $activity); } /** * @return array */ private function pinduoduoStoreBalances(string $start, string $end): array { $connection = $this->mirrorConnection(); $activity = $this->groupedActivity( $connection->table(self::PINDUODUO_ROW_TABLE) ->where('occurred_at', '>=', $start . ' 00:00:00') ->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00'), ); if (! $connection->getSchemaBuilder()->hasTable(self::PINDUODUO_SNAPSHOT_TABLE)) { return $this->mergeNormalizedStoreBalances([], [], $activity); } $monthStart = CarbonImmutable::parse($start)->startOfMonth()->format('Y-m-d'); $balanceDate = $connection->table(self::PINDUODUO_SNAPSHOT_TABLE) ->where('period_end', '<', $monthStart) ->orderBy('period_end') ->value('period_end'); if ($balanceDate === null) { return $this->mergeNormalizedStoreBalances([], [], $activity); } $base = []; foreach ($connection->table(self::PINDUODUO_SNAPSHOT_TABLE) ->where('period_end', $balanceDate) ->select(['store_name', 'normalized_store_name']) ->selectRaw('COALESCE(SUM(balance), 0) AS balance') ->groupBy('store_name', 'normalized_store_name') ->get() as $row) { $normalized = (string) $row->normalized_store_name; $base[$normalized] = [ 'store_name' => $this->storeName($row->store_name), 'balance' => round((float) $row->balance, 2), ]; } $movement = []; foreach ($connection->table(self::PINDUODUO_ROW_TABLE) ->where('occurred_at', '>=', $balanceDate . ' 00:00:00') ->where('occurred_at', '<', $monthStart . ' 00:00:00') ->select('store_name') ->selectRaw( 'COALESCE(SUM(income_amount), 0) AS income, ' . 'COALESCE(SUM(expense_amount), 0) AS expense', ) ->groupBy('store_name') ->get() as $row) { $normalized = JushuitanShopBalanceRepository::normalizeStoreName((string) $row->store_name); $movement[$normalized] = [ 'income' => (float) $row->income, 'expense' => (float) $row->expense, ]; } return $this->mergeNormalizedStoreBalances($base, $movement, $activity); } /** * @return array */ private function jdWalletStoreBalances(string $start, string $end): array { $connection = $this->mirrorConnection(); $generations = $connection->table(self::JD_WALLET_PERIOD_TABLE) ->pluck('active_generation') ->filter() ->map(static fn (mixed $value): string => (string) $value) ->values() ->all(); if ($generations === []) { throw new RuntimeException('jd_wallet_bill_mirror_not_ready'); } $activity = $this->groupedActivity( $connection->table(self::JD_WALLET_ROW_TABLE) ->whereIn('generation', $generations) ->where('occurred_at', '>=', $start . ' 00:00:00') ->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00'), 'income', 'expense', ); $cutoff = $start . ' 00:00:00'; $periodEnd = $this->dayAfter($end) . ' 00:00:00'; $ranked = $connection->table(self::JD_WALLET_ROW_TABLE) ->whereIn('generation', $generations) ->whereNotNull('balance') ->where('occurred_at', '<', $cutoff) ->select([ 'store_name', 'account_name', 'occurred_at', 'income', 'expense', 'balance', 'record_code', ]) ->selectRaw( 'ROW_NUMBER() OVER (' . 'PARTITION BY store_name, account_name ' . 'ORDER BY occurred_at DESC, LENGTH(record_code) DESC, record_code DESC' . ') AS row_rank', ); $latestRows = $connection->query() ->fromSub($ranked, 'latest_jd_balances') ->where('row_rank', 1) ->get(); $opening = []; if ($latestRows->isNotEmpty()) { foreach ($latestRows as $row) { $storeName = $this->storeName($row->store_name); $opening[$storeName] = round( (float) ($opening[$storeName] ?? 0) + (float) $row->balance, 2, ); } } else { $firstRanked = $connection->table(self::JD_WALLET_ROW_TABLE) ->whereIn('generation', $generations) ->whereNotNull('balance') ->where('occurred_at', '>=', $cutoff) ->where('occurred_at', '<', $periodEnd) ->select([ 'store_name', 'account_name', 'occurred_at', 'income', 'expense', 'balance', 'record_code', ]) ->selectRaw( 'ROW_NUMBER() OVER (' . 'PARTITION BY store_name, account_name ' . 'ORDER BY occurred_at ASC, LENGTH(record_code) ASC, record_code ASC' . ') AS row_rank', ); $firstRows = $connection->query() ->fromSub($firstRanked, 'first_jd_balances') ->where('row_rank', 1) ->get(); foreach ($firstRows as $row) { $storeName = $this->storeName($row->store_name); $opening[$storeName] = round( (float) ($opening[$storeName] ?? 0) + (float) $row->balance - (float) $row->income + (float) $row->expense, 2, ); } } return $this->mergeStoreBalances($opening, $activity); } /** * @return array */ private function douyinStoreBalances(string $start, string $end): array { $repository = app(DouyinAuthoritativeBillRepository::class); if (! $repository->available()) { throw new RuntimeException('douyin_authoritative_bills_not_ready'); } return $repository->storeBalances([ 'start' => $start, 'end' => $end, ]); } /** * @return array */ private function vipshopStoreBalances(string $start, string $end): array { $activity = $this->accountBillActivity('vipshop', $start, $end, true); $connection = $this->mirrorConnection(); $opening = []; if ($connection->getSchemaBuilder()->hasTable(self::VIPSHOP_MP_SNAPSHOT_TABLE)) { foreach ($connection->table(self::VIPSHOP_MP_SNAPSHOT_TABLE) ->where('period_end', '2026-06-30') ->select('store_name') ->selectRaw('COALESCE(SUM(balance), 0) AS balance') ->groupBy('store_name') ->get() as $row) { $opening[$this->storeName($row->store_name)] = round((float) $row->balance, 2); } } $monthStart = CarbonImmutable::parse($start)->startOfMonth(); if ($opening !== [] && $monthStart->gt(CarbonImmutable::parse('2026-07-01'))) { $movement = $this->accountBillActivity( 'vipshop', '2026-07-01', $monthStart->subDay()->format('Y-m-d'), true, ); foreach ($opening as $storeName => $balance) { $row = $movement[$storeName] ?? ['income' => 0.0, 'expense' => 0.0]; $opening[$storeName] = round( $balance + (float) $row['income'] - (float) $row['expense'], 2, ); } } return $this->mergeStoreBalances($opening, $activity); } /** * @return array */ private function vipshopJxStoreBalances(string $start, string $end): array { $activity = $this->accountBillActivity('vipshop-jx', $start, $end, true); $openingData = app(VipshopJxOpeningBalanceRepository::class)->openingBalance($start, $end); $opening = $openingData['source'] === 'none' ? null : round((float) $openingData['balance'], 2); $storeNames = array_keys($activity); if ($storeNames === []) { $storeNames = AccountBill::query() ->where('company_id', company_id()) ->where('bill_type', 'vipshop-jx') ->whereNull('deleted_at') ->whereNotNull('store_name') ->where('store_name', '<>', '') ->distinct() ->orderBy('store_name') ->pluck('store_name') ->map(fn (mixed $value): string => $this->storeName($value)) ->all(); } $openingMap = count($storeNames) === 1 && $opening !== null ? [reset($storeNames) => $opening] : []; return $this->mergeStoreBalances($openingMap, $activity); } /** * @return array */ private function xiaohongshuStoreBalances(string $start, string $end): array { $activity = $this->accountBillActivity('xiaohongshu', $start, $end, false); $opening = []; $monthStart = CarbonImmutable::parse($start)->startOfMonth(); if ($monthStart->lte(CarbonImmutable::parse(self::XIAOHONGSHU_BALANCE_START))) { foreach (array_keys($activity) as $storeName) { $opening[$storeName] = 0.0; } } elseif ($monthStart->gt(CarbonImmutable::parse(self::XIAOHONGSHU_BALANCE_START))) { $movement = $this->accountBillActivity( 'xiaohongshu', self::XIAOHONGSHU_BALANCE_START, $monthStart->subDay()->format('Y-m-d'), false, ); foreach ($movement as $storeName => $row) { $opening[$storeName] = round( (float) $row['income'] - (float) $row['expense'], 2, ); } } if ($opening === []) { $storeNames = AccountBill::query() ->where('company_id', company_id()) ->where('bill_type', 'xiaohongshu') ->whereNull('deleted_at') ->whereNotNull('store_name') ->where('store_name', '<>', '') ->distinct() ->pluck('store_name') ->map(fn (mixed $value): string => $this->storeName($value)) ->all(); foreach ($storeNames as $storeName) { $opening[$storeName] = 0.0; } } return $this->mergeStoreBalances($opening, $activity); } /** * @return array */ private function groupedActivity( Builder|EloquentBuilder $query, string $incomeColumn = 'income_amount', string $expenseColumn = 'expense_amount', ): array { $activity = []; foreach ($query ->select('store_name') ->selectRaw( 'COALESCE(SUM(' . $incomeColumn . '), 0) AS income_total, ' . 'COALESCE(SUM(' . $expenseColumn . '), 0) AS expense_total', ) ->groupBy('store_name') ->get() as $row) { $storeName = $this->storeName($row->store_name); $activity[$storeName] = [ 'income' => round((float) ($activity[$storeName]['income'] ?? 0) + (float) $row->income_total, 2), 'expense' => round((float) ($activity[$storeName]['expense'] ?? 0) + (float) $row->expense_total, 2), ]; } return $activity; } /** * @param array $opening * @param array $activity * @return array */ private function mergeStoreBalances(array $opening, array $activity): array { $storeNames = array_values(array_unique(array_merge(array_keys($opening), array_keys($activity)))); $rows = []; foreach ($storeNames as $storeName) { $rows[] = [ 'store_name' => $storeName, 'opening_balance' => array_key_exists($storeName, $opening) ? round((float) $opening[$storeName], 2) : null, 'income' => round((float) ($activity[$storeName]['income'] ?? 0), 2), 'expense' => round((float) ($activity[$storeName]['expense'] ?? 0), 2), ]; } return $rows; } /** * @param array $base * @param array $movement * @param array $activity * @return array */ private function mergeNormalizedStoreBalances(array $base, array $movement, array $activity): array { $normalizedActivity = []; $displayNames = []; foreach ($activity as $storeName => $row) { $normalized = JushuitanShopBalanceRepository::normalizeStoreName($storeName); $displayNames[$normalized] = $storeName; $normalizedActivity[$normalized]['income'] = round( (float) ($normalizedActivity[$normalized]['income'] ?? 0) + (float) $row['income'], 2, ); $normalizedActivity[$normalized]['expense'] = round( (float) ($normalizedActivity[$normalized]['expense'] ?? 0) + (float) $row['expense'], 2, ); } $storeNames = array_values(array_unique(array_merge( array_keys($base), array_keys($movement), array_keys($normalizedActivity), ))); $rows = []; foreach ($storeNames as $normalized) { $storeName = $displayNames[$normalized] ?? ($base[$normalized]['store_name'] ?? '未标注店铺'); $opening = array_key_exists($normalized, $base) ? round( (float) $base[$normalized]['balance'] + (float) ($movement[$normalized]['income'] ?? 0) - (float) ($movement[$normalized]['expense'] ?? 0), 2, ) : null; $rows[] = [ 'store_name' => $storeName, 'opening_balance' => $opening, 'income' => round((float) ($normalizedActivity[$normalized]['income'] ?? 0), 2), 'expense' => round((float) ($normalizedActivity[$normalized]['expense'] ?? 0), 2), ]; } return $rows; } /** * @return array */ private function accountBillActivity( string $billType, string $start, string $end, bool $bySettlePeriod, ): array { $query = AccountBill::query() ->where('company_id', company_id()) ->where('bill_type', $billType) ->whereNull('deleted_at'); if ($bySettlePeriod) { $query->where('payload->settle_period', '>=', substr($start, 0, 7)) ->where('payload->settle_period', '<=', substr($end, 0, 7)); } else { $query->where('occurred_at', '>=', $start . ' 00:00:00') ->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00'); } return $this->groupedActivity($query, 'income', 'expense'); } private function mirrorConnection(): ConnectionInterface { return app(DatabaseManager::class)->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } private function storeName(mixed $value): string { $storeName = trim((string) $value); return $storeName !== '' ? $storeName : '未标注店铺'; } private function dayAfter(string $date): string { return CarbonImmutable::parse($date, 'Asia/Shanghai')->addDay()->format('Y-m-d'); } /** @return iterable> */ private function fetchPlatformRows( string $platform, string $start, string $end, string $flow, ): iterable { $filters = [ 'start' => $start, 'end' => $end, 'business_type' => $flow === 'income' ? '收入' : '支出', ]; return match ($platform) { 'taobao-alipay' => app(StoreReceivingAlipayBillMirrorRepository::class) ->exportRows($filters, 'occurred_at', 'asc'), 'taobao-wechat' => app(TaobaoWechatSettlementBillRepository::class) ->exportAccountBills($filters, 'occurred_at', 'asc'), 'pinduoduo' => app(PinduoduoSettlementBillRepository::class) ->exportAccountBills($filters, 'occurred_at', 'asc'), 'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class) ->exportRows($filters, 'occurred_at', 'asc'), 'douyin' => app(StoreReceivingDouyinBillMirrorRepository::class) ->exportRows($filters, 'occurred_at', 'asc'), 'vipshop', 'vipshop-jx', 'xiaohongshu' => $this->accountBillRows( $platform, $start, $end, $flow, ), default => throw new RuntimeException('unsupported_balance_overview_platform'), }; } /** * 本系统落库账户按对应账单页相同的账期规则读取收入或支出: * 唯品会按 payload.settle_period,小红书等按 occurred_at。 * * @return iterable> */ private function accountBillRows(string $platform, string $start, string $end, string $flow): iterable { $query = AccountBill::query() ->where('company_id', company_id()) ->where('bill_type', $platform) ->where($flow, '>', 0); if (in_array($platform, ['vipshop', 'vipshop-jx'], true)) { $settlePeriod = "JSON_UNQUOTE(JSON_EXTRACT(payload, '$.settle_period'))"; $query->whereRaw($settlePeriod . ' >= ?', [substr($start, 0, 7)]) ->whereRaw($settlePeriod . ' <= ?', [substr($end, 0, 7)]); } else { $query->where('occurred_at', '>=', $start . ' 00:00:00') ->where('occurred_at', '<', CarbonImmutable::parse($end, 'Asia/Shanghai')->addDay()->format('Y-m-d') . ' 00:00:00'); } foreach ($query->orderBy('id')->cursor() as $bill) { yield [ 'store_name' => (string) ($bill->store_name ?? ''), 'bill_item' => $platform === 'vipshop-jx' ? (string) data_get($bill->payload ?? [], 'second_remark', '') : (string) ($bill->bill_item ?? ''), 'income' => (float) $bill->income, 'expense' => (float) $bill->expense, ]; } } /** * @return array{opening_balance:float|null,income:float,expense:float} */ protected function fetchPlatformSummary(string $platform, string $start, string $end): array { $request = Request::create('/ecommerce-finance/account-bills/overview', 'GET', [ 'start' => $start, 'end' => $end, 'page' => 1, 'limit' => 1, 'sort' => 'occurred_at', 'dir' => 'desc', ], [], [], ['HTTP_ACCEPT' => 'application/json']); $response = match ($platform) { 'taobao-alipay' => $this->alipayBills->index( $request, app(StoreReceivingAlipayBillMirrorRepository::class), ), 'taobao-wechat' => $this->taobaoWechatBills->index( $request, app(TaobaoWechatSettlementBillRepository::class), app(TaobaoWechatOpeningBalanceRepository::class), ), 'pinduoduo' => $this->pinduoduoBills->index( $request, app(PinduoduoSettlementBillRepository::class), ), 'jd-wallet', 'douyin', 'vipshop', 'vipshop-jx', 'xiaohongshu' => $this->accountBills->index( $request, $platform, ), default => throw new RuntimeException('unsupported_balance_overview_platform'), }; return $this->summaryFromResponse($response); } /** @return array{opening_balance:float|null,income:float,expense:float} */ private function summaryFromResponse(JsonResponse $response): array { $payload = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); if ($response->getStatusCode() !== 200 || ! is_array($payload)) { throw new RuntimeException((string) ($payload['message'] ?? '平台账单暂时无法读取')); } $summary = $payload['summary'] ?? null; if (! is_array($summary) || ! array_key_exists('opening_balance', $summary) || ! is_numeric($summary['income'] ?? null) || ! is_numeric($summary['expense'] ?? null) ) { throw new RuntimeException('平台账单汇总数据不完整'); } return [ 'opening_balance' => is_numeric($summary['opening_balance']) ? round((float) $summary['opening_balance'], 2) : null, 'income' => round((float) $summary['income'], 2), 'expense' => round((float) $summary['expense'], 2), ]; } private function safeErrorMessage(Throwable $exception): string { $message = trim($exception->getMessage()); return match ($message) { '平台期初余额未就绪' => $message, '平台账单汇总数据不完整' => $message, 'invalid_balance_overview_period' => '日期范围无效', 'unsupported_balance_overview_platform' => '平台类型无效', '' => '平台账单暂时无法读取', default => mb_substr($message, 0, 160), }; } }