['title' => '账户总表', 'icon' => 'account_balance'], 'shops' => ['title' => '店铺台账', 'icon' => 'storefront'], 'flows' => ['title' => '每日资金流水', 'icon' => 'waterfall_chart'], 'transactions' => ['title' => '动账明细', 'icon' => 'receipt_long'], 'mapping' => ['title' => '账户映射', 'icon' => 'alt_route'], 'sources' => ['title' => '数据来源', 'icon' => 'database'], ]; private const ACCOUNT_FILTER_EMPTY = '__empty__'; public function page(?string $tab = null): array { $tab = (string) (request('tab') ?: $tab); $tab = array_key_exists($tab, self::TABS) ? $tab : 'accounts'; $openingDate = (string) config('finance_ledger.opening_date', '2026-10-17'); $accounts = $this->accounts(); $shops = $this->shops(); $flows = $this->flows(); $transactions = $this->transactions(); return [ 'view' => 'business.pages.financial-ledger.ledger', 'ledgerTab' => $tab, 'ledgerTabs' => self::TABS, 'ledgerOpeningDate' => $openingDate, 'ledgerAccounts' => $accounts['rows'], 'ledgerAccountSummary' => $accounts['summary'], 'ledgerShops' => $shops['rows'], 'ledgerShopSummary' => $shops['summary'], 'ledgerFlows' => $flows['rows'], 'ledgerFlowSummary' => $flows['summary'], 'ledgerTransactions' => $transactions['rows'], 'ledgerTransactionSummary' => $transactions['summary'], 'ledgerMapping' => $this->mapping(), 'ledgerSourceSummary' => $this->sourceSummary(), 'ledgerMeta' => $this->meta(), 'ledgerAccountOptions' => $this->accountOptions(), 'ledgerAccountFilterOptions' => $this->accountFilterOptions(), 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, 'ledgerCanEditMapping' => FinanceLedgerAccess::canEditMapping(user()), 'ledgerCanEditAccountStatus' => FinanceLedgerAccess::canEditAccountStatus(user()), 'ledgerCanEditAccountType' => FinanceLedgerAccess::canEditAccountType(user()), ]; } private function accounts(): array { $query = $this->db()->table('fin_accounts'); $keyword = trim((string) request('accounts_q', '')); if ($keyword !== '') { $query->where(function (Builder $builder) use ($keyword): void { foreach (['company_name', 'account_short', 'account_no', 'owner'] as $column) { $builder->orWhere($column, 'like', '%' . $keyword . '%'); } }); } $accountShort = trim((string) request('accounts_short', '')); if ($accountShort !== '') { $query->where('account_short', 'like', '%' . $accountShort . '%'); } $status = trim((string) request('accounts_status', '')); if ($status !== '' && array_key_exists($status, FinanceLedgerAccountStatus::OPTIONS)) { if ($status === FinanceLedgerAccountStatus::NORMAL) { $nonNormalStatuses = array_values(array_diff( array_keys(FinanceLedgerAccountStatus::OPTIONS), [FinanceLedgerAccountStatus::NORMAL], )); $query->whereNotIn( 'account_short', $this->db()->table('fin_account_statuses') ->select('account_short') ->whereIn('status', $nonNormalStatuses), ); } else { $query->whereIn( 'account_short', $this->db()->table('fin_account_statuses') ->select('account_short') ->where('status', $status), ); } } $region = trim((string) request('accounts_region', '')); if ($region !== '') { if ($region === self::ACCOUNT_FILTER_EMPTY) { $query->where(function (Builder $builder): void { $builder->whereNull('region')->orWhere('region', ''); }); } else { $query->where('region', $region); } } $accountType = trim((string) request('accounts_type', '')); if ($accountType !== '') { $query->where(function (Builder $effectiveType) use ($accountType): void { $effectiveType->where(function (Builder $baseType) use ($accountType): void { $baseType->whereNotIn( 'account_short', $this->db()->table('fin_account_type_overrides')->select('account_short'), ); if ($accountType === self::ACCOUNT_FILTER_EMPTY) { $baseType->where(function (Builder $blank): void { $blank->whereNull('account_type')->orWhere('account_type', ''); }); } else { $baseType->where('account_type', $accountType); } }); $overrideType = $this->db()->table('fin_account_type_overrides')->select('account_short'); if ($accountType === self::ACCOUNT_FILTER_EMPTY) { $overrideType->where(function (Builder $blank): void { $blank->whereNull('account_type')->orWhere('account_type', ''); }); } else { $overrideType->where('account_type', $accountType); } $effectiveType->orWhereIn('account_short', $overrideType); }); } $summaryQuery = clone $query; $summary = [ 'count' => (clone $query)->count(), 'balance' => round((float) (clone $summaryQuery)->sum('current_balance'), 2), 'blocked' => round((float) (clone $summaryQuery)->sum('blocked_amount'), 2), ]; $rows = $query ->orderBy('company_name') ->orderBy('account_short') ->paginate(100, ['*'], 'accounts_page') ->withQueryString(); $accountShorts = $rows->pluck('account_short')->filter()->unique()->values()->all(); $statuses = $accountShorts === [] ? collect() : $this->db()->table('fin_account_statuses') ->whereIn('account_short', $accountShorts) ->pluck('status', 'account_short'); $typeOverrides = $accountShorts === [] ? collect() : $this->db()->table('fin_account_type_overrides') ->whereIn('account_short', $accountShorts) ->pluck('account_type', 'account_short'); $rows->getCollection()->transform(static function ($row) use ($statuses, $typeOverrides) { $row->account_status = FinanceLedgerAccountStatus::normalize($statuses[$row->account_short] ?? null); if ($typeOverrides->has($row->account_short)) { $row->account_type = (string) ($typeOverrides->get($row->account_short) ?? ''); } $row->account_should_keep = FinanceLedgerAccountShouldKeep::display( $row->account_should_keep ?? null, $row->company_plan ?? null, ); return $row; }); return ['rows' => $rows, 'summary' => $summary]; } private function shops(): array { $query = $this->db()->table('fin_shops'); foreach (['shops_q' => ['shop_name', 'platform', 'company_name'], 'shops_platform' => ['platform'], 'shops_company' => ['company_name']] as $param => $columns) { $value = trim((string) request($param, '')); if ($value === '') { continue; } $query->where(function (Builder $builder) use ($columns, $value): void { foreach ($columns as $column) { $builder->orWhere($column, 'like', '%' . $value . '%'); } }); } $summary = [ 'count' => (clone $query)->count(), 'balance' => round((float) (clone $query)->sum('shop_balance'), 2), ]; return [ 'rows' => $query->orderBy('company_name')->orderBy('shop_name') ->paginate(100, ['*'], 'shops_page')->withQueryString(), 'summary' => $summary, ]; } private function flows(): array { $query = $this->db()->table('fin_daily_flows'); foreach (['flows_date' => 'flow_date', 'flows_company' => 'company_name', 'flows_account' => 'account_short'] as $param => $column) { $value = trim((string) request($param, '')); if ($value !== '') { $query->where($column, $param === 'flows_company' || $param === 'flows_account' ? 'like' : '=', $param === 'flows_company' || $param === 'flows_account' ? '%' . $value . '%' : $value); } } $summary = [ 'count' => (clone $query)->count(), 'closing' => round((float) (clone $query)->sum('closing_balance'), 2), 'inflow' => round((float) (clone $query)->sum('inflow'), 2), 'outflow' => round((float) (clone $query)->sum('outflow'), 2), ]; return [ 'rows' => $query->orderBy('flow_date')->orderBy('account_short') ->paginate(100, ['*'], 'flows_page')->withQueryString(), 'summary' => $summary, ]; } private function transactions(): array { $query = $this->transactionQuery(); $keyword = trim((string) request('txns_q', '')); if ($keyword !== '') { $query->where(function (Builder $builder) use ($keyword): void { $builder->where('t.detail', 'like', '%' . $keyword . '%') ->orWhere('t.approval_no', 'like', '%' . $keyword . '%') ->orWhere('t.account_short', 'like', '%' . $keyword . '%') ->orWhere('m.account_short', 'like', '%' . $keyword . '%'); }); } $account = trim((string) request('txns_account', '')); if ($account !== '') { $query->whereRaw("COALESCE(NULLIF(m.account_short,''), NULLIF(t.account_short,''), '未匹配') LIKE ?", ['%' . $account . '%']); } $from = trim((string) request('txns_from', '')); if ($from !== '') { $query->where('t.txn_date', '>=', $from); } $to = trim((string) request('txns_to', '')); if ($to !== '') { $query->where('t.txn_date', '<=', $to); } $source = trim((string) request('txns_source', '')); if ($source !== '') { $query->where('t.source', $source); } $summary = [ 'count' => (clone $query)->count(), 'income' => round((float) (clone $query)->sum('t.income'), 2), 'expense' => round((float) (clone $query)->sum('t.expense'), 2), ]; return [ 'rows' => $query->orderByDesc('t.txn_date')->orderByDesc('t.id') ->paginate(100, ['*'], 'txns_page')->withQueryString(), 'summary' => $summary, ]; } private function transactionQuery(): Builder { return $this->db()->table('fin_transactions as t') ->leftJoin('fin_account_map as m', function ($join): void { $join->on('m.source', '=', 't.source')->on('m.source_value', '=', 't.source_value'); }) ->selectRaw("t.*, COALESCE(NULLIF(m.account_short,''), NULLIF(t.account_short,''), '未匹配') AS effective_account_short"); } private function mapping(): array { $query = $this->db()->table('fin_account_map'); $source = trim((string) request('mapping_source', '')); if ($source !== '') { $query->where('source', $source); } $keyword = trim((string) request('mapping_q', '')); if ($keyword !== '') { $query->where(function (Builder $builder) use ($keyword): void { $builder->where('source_value', 'like', '%' . $keyword . '%') ->orWhere('account_short', 'like', '%' . $keyword . '%'); }); } if (request()->boolean('mapping_unmapped')) { $query->where(function (Builder $builder): void { $builder->whereNull('account_short')->orWhere('account_short', ''); }); } $total = (clone $query)->count(); $mapped = (clone $query)->whereNotNull('account_short')->where('account_short', '<>', '')->count(); return [ 'rows' => $query->orderByDesc('txn_count')->orderBy('source_value') ->paginate(100, ['*'], 'mapping_page')->withQueryString(), 'total' => $total, 'mapped' => $mapped, 'all_total' => $this->db()->table('fin_account_map')->count(), 'all_mapped' => $this->db()->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count(), 'sources' => $this->db()->table('fin_account_map')->select('source')->distinct()->orderBy('source')->pluck('source'), ]; } private function sourceSummary(): array { return $this->db()->table('fin_transactions') ->selectRaw('source, process_type, COUNT(*) AS rows_count, SUM(income) AS income, SUM(expense) AS expense, MIN(txn_date) AS first_date, MAX(txn_date) AS last_date') ->groupBy('source', 'process_type') ->orderBy('source') ->orderByDesc('rows_count') ->get() ->map(static fn ($row): array => (array) $row) ->all(); } private function meta(): array { return [ 'accounts' => $this->db()->table('fin_accounts')->count(), 'shops' => $this->db()->table('fin_shops')->count(), 'companies' => $this->db()->table('fin_companies')->count(), 'opening_rows' => $this->db()->table('fin_account_opening')->count(), 'opening_sum' => round((float) $this->db()->table('fin_account_opening')->sum('opening_balance'), 2), 'transactions' => $this->db()->table('fin_transactions')->count(), 'income' => round((float) $this->db()->table('fin_transactions')->sum('income'), 2), 'expense' => round((float) $this->db()->table('fin_transactions')->sum('expense'), 2), 'flows' => $this->db()->table('fin_daily_flows')->count(), 'mapping_total' => $this->db()->table('fin_account_map')->count(), 'mapping_done' => $this->db()->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count(), 'last_synced_at' => $this->db()->table('fin_transactions')->max('synced_at'), ]; } private function accountOptions(): array { return $this->db()->table('fin_accounts') ->select('account_short')->distinct()->orderBy('account_short')->pluck('account_short')->all(); } private function accountFilterOptions(): array { $baseTypes = $this->db()->table('fin_accounts') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->orderBy('account_type')->pluck('account_type')->all(); $overrideTypes = $this->db()->table('fin_account_type_overrides') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->orderBy('account_type')->pluck('account_type')->all(); $types = FinanceLedgerAccountType::options($baseTypes, $overrideTypes); return [ 'regions' => $this->db()->table('fin_accounts') ->whereNotNull('region')->where('region', '<>', '') ->distinct()->orderBy('region')->pluck('region')->all(), 'types' => $types, ]; } private function db() { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); } }