connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } public static function supportsScope(string $sourceScope): bool { return in_array($sourceScope, [self::SCOPE_TAOBAO, self::SCOPE_JD_WALLET, 'all'], true); } /** * @return list */ public function latestBalances( string $sourceScope, string $balanceDate, array $storeNames = [], ): array { if (! self::supportsScope($sourceScope)) { throw new InvalidArgumentException('当前账单来源没有累计账户余额字段。'); } $date = CarbonImmutable::createFromFormat('!Y-m-d', $balanceDate); if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== $balanceDate) { throw new InvalidArgumentException('余额日期必须使用 YYYY-MM-DD 格式。'); } $before = $date->addDay()->format('Y-m-d') . ' 00:00:00'; $storeNames = array_values(array_unique(array_filter(array_map( static fn (mixed $storeName): string => trim((string) $storeName), $storeNames, )))); if ($sourceScope === self::SCOPE_TAOBAO) { return $this->aggregate( $this->taobaoRows($before, $storeNames), 'alipay_account', self::SCOPE_TAOBAO, '财务系统/店铺收款账户账单/淘系支付宝', ); } if ($sourceScope === self::SCOPE_JD_WALLET) { return $this->aggregate( $this->jdWalletRows($before, $storeNames), 'account_name', self::SCOPE_JD_WALLET, '财务系统/店铺收款账户账单/京东钱包', ); } return [ ...$this->latestBalances(self::SCOPE_TAOBAO, $balanceDate, $storeNames), ...$this->latestBalances(self::SCOPE_JD_WALLET, $balanceDate, $storeNames), ]; } /** @return Collection */ private function taobaoRows(string $before, array $storeNames): Collection { if (! Schema::connection($this->connection->getName())->hasTable('taobao_alipay_bill_rows')) { throw new RuntimeException('finance_taobao_balance_source_not_ready'); } return $this->latestRows( 'taobao_alipay_bill_rows', 'alipay_account', 'balance_amount', $before, $storeNames, ); } /** @return Collection */ private function jdWalletRows(string $before, array $storeNames): Collection { $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable('jd_wallet_bill_rows') || ! $schema->hasTable('jd_wallet_bill_periods')) { throw new RuntimeException('finance_jd_wallet_balance_source_not_ready'); } $generations = $this->connection->table('jd_wallet_bill_periods') ->whereNotNull('active_generation') ->pluck('active_generation') ->filter() ->values() ->all(); if ($generations === []) { throw new RuntimeException('finance_jd_wallet_balance_source_not_ready'); } return $this->latestRows( 'jd_wallet_bill_rows', 'account_name', 'balance', $before, $storeNames, $generations, ); } /** * 先按店铺和账户截取最新时间,再取同一时间的最大记录编码。 * 避免在千万级账单表上对整个历史窗口做 ROW_NUMBER()。 * * @param list $storeNames * @param list|null $generations * @return Collection */ private function latestRows( string $table, string $accountField, string $balanceField, string $before, array $storeNames, ?array $generations = null, ): Collection { $latestGroups = $this->connection->table($table) ->when( $generations !== null, static fn ($query) => $query->whereIn('generation', $generations), ) ->whereNotNull('store_name') ->where('store_name', '<>', '') ->whereNotNull($balanceField) ->where('occurred_at', '<', $before) ->when( $storeNames !== [], static fn ($query) => $query->whereIn('store_name', $storeNames), ) ->select(['store_name']) ->selectRaw("COALESCE({$accountField}, '') AS account_key") ->selectRaw('MAX(occurred_at) AS latest_at') ->groupByRaw("store_name, COALESCE({$accountField}, '')"); $tableAlias = $this->connection->getTablePrefix() . 'bill_rows'; $ranked = $this->connection->table($table . ' as bill_rows') ->joinSub( $latestGroups, 'latest_groups', function ($join) use ($accountField, $tableAlias): void { $join ->on('bill_rows.store_name', '=', 'latest_groups.store_name') ->on( $this->connection->raw("COALESCE({$tableAlias}.{$accountField}, '')"), '=', 'latest_groups.account_key', ); }, ) ->when( $generations !== null, static fn ($query) => $query->whereIn('bill_rows.generation', $generations), ) ->whereColumn('bill_rows.occurred_at', '=', 'latest_groups.latest_at') ->select([ 'bill_rows.record_code', 'bill_rows.store_name', "bill_rows.{$accountField}", 'bill_rows.occurred_at', "bill_rows.{$balanceField}", ]) ->selectRaw( "ROW_NUMBER() OVER (" . "PARTITION BY {$tableAlias}.store_name, COALESCE({$tableAlias}.{$accountField}, '') " . "ORDER BY LENGTH({$tableAlias}.record_code) DESC, {$tableAlias}.record_code DESC" . ') AS balance_rank', ); return $this->connection->query() ->fromSub($ranked, 'ranked_balances') ->where('balance_rank', 1) ->get([ 'record_code', 'store_name', $accountField, 'occurred_at', $balanceField, ]); } /** * 同一店铺存在多个账户时,余额按店铺汇总;与页面「账户余额合计」的 * 展示口径一致。 * * @param Collection $rows * @return list */ private function aggregate( Collection $rows, string $accountField, string $sourceScope, string $sourceFileName, ): array { $aggregated = []; foreach ($rows as $row) { $storeName = trim((string) ($row->store_name ?? '')); $sourcePlatform = JushuitanShopBalanceRepository::sourcePlatform($storeName); $platformKey = JushuitanShopBalanceRepository::platformKey((string) $sourcePlatform); $normalizedStoreName = JushuitanShopBalanceRepository::normalizeStoreName($storeName); if ($platformKey === null || $storeName === '' || $normalizedStoreName === '') { continue; } $key = $platformKey . "\0" . $normalizedStoreName; if (! isset($aggregated[$key])) { $aggregated[$key] = [ 'source_platform' => (string) $sourcePlatform, 'store_name' => $storeName, 'normalized_store_name' => $normalizedStoreName, 'balance' => 0.0, 'occurred_at' => '', 'accounts' => [], ]; } $account = trim((string) ($row->{$accountField} ?? '')); if ($account !== '') { $aggregated[$key]['accounts'][$account] = true; } $aggregated[$key]['balance'] += (float) $row->{$sourceScope === self::SCOPE_TAOBAO ? 'balance_amount' : 'balance'}; if ((string) $row->occurred_at > $aggregated[$key]['occurred_at']) { $aggregated[$key]['occurred_at'] = (string) $row->occurred_at; } } $result = []; foreach ($aggregated as $key => $item) { $accounts = array_keys($item['accounts']); sort($accounts, SORT_NATURAL); $result[] = [ 'source_scope' => $sourceScope, 'source_platform' => $item['source_platform'], 'store_name' => $item['store_name'], 'normalized_store_name' => $item['normalized_store_name'], 'platform_account_id' => $accounts === [] ? null : mb_substr(implode(',', $accounts), 0, 191), 'balance' => round((float) $item['balance'], 6), 'occurred_at' => $item['occurred_at'], 'source_row_key' => hash('sha256', 'finance' . "\0" . $key), 'source_file_name' => $sourceFileName, ]; } usort( $result, static fn (array $left, array $right): int => strcmp( $left['source_scope'] . "\0" . $left['normalized_store_name'], $right['source_scope'] . "\0" . $right['normalized_store_name'], ), ); return $result; } }