*/ 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 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, ]; } /** * 收入合计的平台、店铺、账单项目明细。 * * 各平台仍沿用对应账单页的同一数据源和账期口径;这里只汇总收入行, * 明细金额统一返回元,页面再按万元展示。 * * @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, ]; } /** @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 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), }; } }