['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'], 'ledgerShopBalanceDate' => $this->latestShopBalanceDate(), 'ledgerFlows' => $flows['rows'], 'ledgerFlowSummary' => $flows['summary'], 'ledgerTransactions' => $transactions['rows'], 'ledgerTransactionSummary' => $transactions['summary'], 'ledgerMapping' => $this->mapping(), 'ledgerSourceSummary' => $this->sourceSummary(), 'ledgerMeta' => $this->meta(), 'ledgerAccountOptions' => $this->accountOptions(), 'ledgerManualTransactionAccountOptions' => $this->manualTransactionAccountOptions(), 'ledgerTransactionAccountHeaderOptions' => $this->transactionAccountHeaderOptions(), 'ledgerTransactionDateHeaderOptions' => $this->transactionDateHeaderOptions(), 'ledgerAccountFilterOptions' => $this->accountFilterOptions(), 'ledgerCompanyOptions' => $this->companyOptions(), 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, 'ledgerAccountPlanOptions' => $this->accountPlanOptions(), 'ledgerAccountKeepOptions' => FinanceLedgerAccountShouldKeep::OPTIONS, 'ledgerCanEditMapping' => FinanceLedgerAccess::canEditMapping(user()), 'ledgerCanEditAccountStatus' => FinanceLedgerAccess::canEditAccountStatus(user()), 'ledgerCanEditAccountType' => FinanceLedgerAccess::canEditAccountType(user()), ]; } private function accounts(): array { $query = $this->db()->table('fin_accounts as a'); $keyword = trim((string) request('accounts_q', '')); if ($keyword !== '') { $query->where(function (Builder $builder) use ($keyword): void { foreach (['a.company_name', 'a.account_short', 'a.account_no', 'a.owner'] as $column) { $builder->orWhere($column, 'like', '%' . $keyword . '%'); } }); } $accountShort = trim((string) request('accounts_short', '')); if ($accountShort !== '') { $query->where('a.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( 'a.account_short', $this->db()->table('fin_account_statuses') ->select('account_short') ->whereIn('status', $nonNormalStatuses), ); } else { $query->whereIn( 'a.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('a.region')->orWhere('a.region', ''); }); } else { $query->where('a.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( 'a.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('a.account_type')->orWhere('a.account_type', ''); }); } else { $baseType->where('a.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('a.account_short', $overrideType); }); } $balanceRange = trim((string) request('accounts_balance', '')); if ($balanceRange === '0-5') { $query->where('a.current_balance', '>=', 0)->where('a.current_balance', '<', 50000); } elseif ($balanceRange === '5-20') { $query->where('a.current_balance', '>=', 50000)->where('a.current_balance', '<', 200000); } elseif ($balanceRange === '20-plus') { $query->where('a.current_balance', '>=', 200000); } $headerColumns = $this->accountHeaderColumns(); $this->applyLedgerHeaderFilter($query, 'accounts', $headerColumns); $summaryQuery = clone $query; $summary = [ 'count' => (clone $query)->count(), 'unretained' => FinanceLedgerAccountShouldKeep::applyUnretainedCondition( clone $query, 'a.account_should_keep', 'a.company_plan', )->count(), 'current_balance' => round((float) (clone $summaryQuery)->sum('a.current_balance'), 2), 'balance' => round((float) (clone $summaryQuery)->sum('a.daily_available'), 2), 'blocked' => round((float) (clone $summaryQuery)->sum('a.blocked_amount'), 2), ]; $sorted = $this->applyLedgerHeaderSort($query, 'accounts', $headerColumns); if (! $sorted) { $query->orderBy('a.company_name')->orderBy('a.account_short'); } $rows = $query ->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 . '%'); } }); } $headerColumns = $this->shopHeaderColumns(); $this->applyLedgerHeaderFilter($query, 'shops', $headerColumns); $summary = [ 'count' => (clone $query)->count(), 'balance' => round((float) (clone $query)->sum('shop_balance'), 2), ]; $sorted = $this->applyLedgerHeaderSort($query, 'shops', $headerColumns); if (! $sorted) { $query->orderBy('company_name')->orderBy('shop_name'); } return [ 'rows' => $query ->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); } } $headerColumns = $this->flowHeaderColumns(); $this->applyLedgerHeaderFilter($query, 'flows', $headerColumns); $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), ]; $sorted = $this->applyLedgerHeaderSort($query, 'flows', $headerColumns); if (! $sorted) { $query->orderBy('flow_date')->orderBy('account_short'); } return [ 'rows' => $query ->paginate(100, ['*'], 'flows_page')->withQueryString(), 'summary' => $summary, ]; } private function transactions(): array { $query = $this->transactionQuery(); $this->applyTransactionFilters($query); $headerColumns = $this->transactionHeaderColumns(); $summary = $this->transactionSummaryMetrics($query); $sorted = $this->applyLedgerHeaderSort($query, 'transactions', $headerColumns); if (! $sorted) { $query->orderByDesc('t.txn_date')->orderByDesc('t.id'); } return [ 'rows' => $query ->paginate(100, ['*'], 'txns_page')->withQueryString(), 'summary' => $summary, ]; } public function transactionSummary(): array { $query = $this->transactionQuery(); $this->applyTransactionFilters($query); return $this->transactionSummaryMetrics($query); } private function applyTransactionFilters(Builder $query): void { $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 !== '') { $effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort']; $query->whereRaw($effectiveAccount . ' 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); } $this->applyLedgerHeaderFilter($query, 'transactions', $this->transactionHeaderColumns()); } private function transactionSummaryMetrics(Builder $query): array { return [ 'count' => (clone $query)->count(), 'income' => round((float) (clone $query)->sum('t.income'), 2), 'expense' => round((float) (clone $query)->sum('t.expense'), 2), ]; } private function transactionQuery(): Builder { $effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort']; 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'); }) ->select([ 't.id', 't.txn_date', 't.account_short', 't.detail', 't.income', 't.expense', 't.approval_no', 't.source', 't.source_value', 't.process_type', 't.row_key', 't.synced_at', 't.created_at', 't.updated_at', ]) ->selectRaw($effectiveAccount . ' AS effective_account_short'); } private function transactionAccountHeaderOptions(): array { $effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort']; 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($effectiveAccount . ' AS filter_value') ->distinct() ->orderBy('filter_value') ->pluck('filter_value') ->map(static fn (mixed $value): string => trim((string) $value)) ->filter() ->unique() ->mapWithKeys(static fn (string $value): array => [$value => $value]) ->all(); } private function transactionDateHeaderOptions(): array { return $this->db()->table('fin_transactions') ->select('txn_date') ->distinct() ->orderByDesc('txn_date') ->pluck('txn_date') ->map(static fn (mixed $value): string => (string) $value) ->filter() ->unique() ->mapWithKeys(static fn (string $value): array => [$value => $value]) ->all(); } 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', ''); }); } $headerColumns = $this->mappingHeaderColumns(); $this->applyLedgerHeaderFilter($query, 'mapping', $headerColumns); $total = (clone $query)->count(); $mapped = (clone $query)->whereNotNull('account_short')->where('account_short', '<>', '')->count(); $sorted = $this->applyLedgerHeaderSort($query, 'mapping', $headerColumns); if (! $sorted) { $query->orderByDesc('txn_count')->orderBy('source_value'); } return [ 'rows' => $query ->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 { $rows = $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 function ($row): array { $data = (array) $row; $data['date_range'] = trim((string) ($data['first_date'] ?? '') . ' ~ ' . (string) ($data['last_date'] ?? '')); return $data; }) ->all(); return $this->filterAndSortHeaderRows($rows, 'sources', $this->sourceHeaderColumns()); } 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 latestShopBalanceDate(): ?string { if (! Schema::connection($this->db()->getName())->hasTable(JushuitanShopBalanceRepository::TABLE)) { return null; } $latest = $this->db()->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', '<=', now()->startOfDay()->subDay()->toDateString()) ->max('balance_date'); return $latest === null ? null : substr((string) $latest, 0, 10); } private function accountOptions(): array { return $this->db()->table('fin_accounts') ->select('account_short')->distinct()->orderBy('account_short')->pluck('account_short')->all(); } private function manualTransactionAccountOptions(): array { return $this->db()->table('fin_accounts') ->select(['account_short', 'company_name']) ->whereNotNull('account_short') ->where('account_short', '<>', '') ->orderBy('account_short') ->orderBy('company_name') ->orderBy('id') ->get() ->groupBy('account_short') ->map(static function ($rows, string $accountShort): array { $first = $rows->first(); $companyName = trim((string) ($first->company_name ?? '')); $accountCount = $rows->count(); return [ 'value' => $accountShort, 'label' => $accountShort . ($companyName !== '' ? ' · ' . $companyName : '') . ($accountCount > 1 ? '(' . $accountCount . ' 个账号)' : ''), ]; }) ->values() ->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 accountPlanOptions(): array { return $this->db()->table('fin_accounts') ->whereNotNull('company_plan') ->where('company_plan', '<>', '') ->distinct() ->orderBy('company_plan') ->pluck('company_plan') ->map(static fn (mixed $value): string => trim((string) $value)) ->filter() ->unique() ->values() ->all(); } private function companyOptions(): array { return $this->db()->table('fin_companies') ->whereNotNull('name') ->pluck('name') ->merge( $this->db()->table('fin_accounts') ->whereNotNull('company_name') ->distinct() ->pluck('company_name'), ) ->map(static fn (mixed $value): string => trim((string) $value)) ->filter() ->unique() ->sortBy(static fn (string $value): string => mb_strtolower($value)) ->values() ->all(); } private function applyLedgerHeaderFilter(Builder $query, string $tab, array $columns): void { $column = trim((string) request($tab . '_filter_column', '')); $value = trim((string) request($tab . '_filter_value', '')); if ($value === '' || ! isset($columns[$column])) { return; } $definition = $columns[$column]; $expression = $definition['filter'] ?? $definition['sort'] ?? null; if (! is_string($expression) || $expression === '') { return; } $type = $definition['type'] ?? 'text'; if ($type === 'number') { if (is_numeric($value)) { $query->whereRaw($expression . ' = ?', [$value]); } return; } if (in_array($type, ['enum', 'date'], true)) { $query->whereRaw($expression . ' = ?', [$value]); return; } $query->whereRaw($expression . ' LIKE ?', ['%' . $value . '%']); } private function applyLedgerHeaderSort(Builder $query, string $tab, array $columns): bool { $column = trim((string) request($tab . '_sort', '')); if (! isset($columns[$column])) { return false; } $expression = $columns[$column]['sort'] ?? null; if (! is_string($expression) || $expression === '') { return false; } $direction = strtolower((string) request($tab . '_direction', 'asc')) === 'desc' ? 'desc' : 'asc'; $query->orderByRaw($expression . ' ' . $direction); return true; } private function filterAndSortHeaderRows(array $rows, string $tab, array $columns): array { $filterColumn = trim((string) request($tab . '_filter_column', '')); $filterValue = trim((string) request($tab . '_filter_value', '')); if ($filterValue !== '' && isset($columns[$filterColumn])) { $valueColumn = $columns[$filterColumn]['value'] ?? $filterColumn; $type = $columns[$filterColumn]['type'] ?? 'text'; $rows = array_values(array_filter( $rows, static function (array $row) use ($valueColumn, $filterValue, $type): bool { $value = trim((string) ($row[$valueColumn] ?? '')); if ($type === 'number') { return is_numeric($filterValue) && is_numeric($value) && (float) $value === (float) $filterValue; } if (in_array($type, ['enum', 'date'], true)) { return $value === $filterValue; } return mb_stripos($value, $filterValue) !== false; }, )); } $sortColumn = trim((string) request($tab . '_sort', '')); if (isset($columns[$sortColumn])) { $valueColumn = $columns[$sortColumn]['value'] ?? $sortColumn; $direction = strtolower((string) request($tab . '_direction', 'asc')) === 'desc' ? -1 : 1; $type = $columns[$sortColumn]['type'] ?? 'text'; usort($rows, static function (array $left, array $right) use ($valueColumn, $direction, $type): int { $leftValue = $left[$valueColumn] ?? null; $rightValue = $right[$valueColumn] ?? null; if ($type === 'number') { return ((float) $leftValue <=> (float) $rightValue) * $direction; } return strnatcasecmp((string) $leftValue, (string) $rightValue) * $direction; }); } return $rows; } private function accountHeaderColumns(): array { $a = $this->queryAlias('a'); $statusTable = $this->tableName('fin_account_statuses'); $typeTable = $this->tableName('fin_account_type_overrides'); $statusExpression = "COALESCE((SELECT NULLIF(s.status, '') FROM {$statusTable} s WHERE s.account_short = {$a}.account_short LIMIT 1), 'normal')"; $typeExpression = "COALESCE((SELECT NULLIF(o.account_type, '') FROM {$typeTable} o WHERE o.account_short = {$a}.account_short LIMIT 1), NULLIF({$a}.account_type, ''), '')"; $keepExpression = "COALESCE(NULLIF({$a}.account_should_keep, ''), CASE WHEN COALESCE({$a}.company_plan, '') = '' THEN '' WHEN {$a}.company_plan LIKE '%保留%' THEN '是' ELSE '否' END)"; return [ 'company_name' => ['sort' => $a . '.company_name', 'type' => 'text'], 'account_short' => ['sort' => $a . '.account_short', 'type' => 'text'], 'account_type' => ['sort' => $typeExpression, 'type' => 'enum'], 'account_status' => ['sort' => $statusExpression, 'type' => 'enum'], 'region' => ['sort' => $a . '.region', 'type' => 'text'], 'account_no' => ['sort' => $a . '.account_no', 'type' => 'text'], 'bank_name' => ['sort' => $a . '.bank_name', 'type' => 'text'], 'bank_city_branch' => ['sort' => $a . '.bank_city_branch', 'type' => 'text'], 'company_plan' => ['sort' => $a . '.company_plan', 'type' => 'enum'], 'account_should_keep' => ['sort' => $keepExpression, 'type' => 'enum'], 'account_last4' => ['sort' => $a . '.account_last4', 'type' => 'text'], 'single_limit' => ['sort' => "CAST({$a}.single_limit AS DECIMAL(18, 2))", 'filter' => $a . '.single_limit', 'type' => 'number'], 'daily_limit' => ['sort' => "CAST({$a}.daily_limit AS DECIMAL(18, 2))", 'filter' => $a . '.daily_limit', 'type' => 'number'], 'monthly_limit' => ['sort' => "CAST({$a}.monthly_limit AS DECIMAL(18, 2))", 'filter' => $a . '.monthly_limit', 'type' => 'number'], 'yearly_limit' => ['sort' => "CAST({$a}.yearly_limit AS DECIMAL(18, 2))", 'filter' => $a . '.yearly_limit', 'type' => 'number'], 'current_balance' => ['sort' => $a . '.current_balance', 'type' => 'number'], 'daily_available' => ['sort' => $a . '.daily_available', 'type' => 'number'], 'blocked_amount' => ['sort' => $a . '.blocked_amount', 'type' => 'number'], 'daily_withdraw' => ['sort' => $a . '.daily_available', 'type' => 'number'], 'clearing_days' => ['sort' => $a . '.clearing_days', 'type' => 'number'], 'raise_target' => ['sort' => "CAST({$a}.raise_target AS DECIMAL(18, 2))", 'filter' => $a . '.raise_target', 'type' => 'number'], 'raise_status' => ['sort' => $a . '.raise_status', 'type' => 'text'], 'raise_actions' => ['sort' => $a . '.raise_actions', 'type' => 'text'], 'done_actions' => ['sort' => $a . '.done_actions', 'type' => 'text'], 'next_action' => ['sort' => $a . '.next_action', 'type' => 'text'], 'remark' => ['sort' => $a . '.remark', 'type' => 'text'], 'owner' => ['sort' => $a . '.owner', 'type' => 'text'], ]; } private function shopHeaderColumns(): array { return [ 'platform' => ['sort' => 'platform', 'type' => 'text'], 'shop_name' => ['sort' => 'shop_name', 'type' => 'text'], 'company_name' => ['sort' => 'company_name', 'type' => 'text'], 'account_short' => ['sort' => 'account_short', 'type' => 'text'], 'status' => ['sort' => 'status', 'type' => 'text'], 'payout_account' => ['sort' => 'payout_account', 'type' => 'text'], 'shop_balance' => ['sort' => 'shop_balance', 'type' => 'number'], 'remark' => ['sort' => 'remark', 'type' => 'text'], ]; } private function flowHeaderColumns(): array { return [ 'flow_date' => ['sort' => 'flow_date', 'type' => 'date'], 'company_name' => ['sort' => 'company_name', 'type' => 'text'], 'account_short' => ['sort' => 'account_short', 'type' => 'text'], 'opening_balance' => ['sort' => 'opening_balance', 'type' => 'number'], 'inflow' => ['sort' => 'inflow', 'type' => 'number'], 'outflow' => ['sort' => 'outflow', 'type' => 'number'], 'closing_balance' => ['sort' => 'closing_balance', 'type' => 'number'], 'remark' => ['sort' => 'remark', 'type' => 'text'], ]; } private function transactionHeaderColumns(): array { $t = $this->queryAlias('t'); $m = $this->queryAlias('m'); $effectiveAccount = "COALESCE(NULLIF({$m}.account_short, ''), NULLIF({$t}.account_short, ''), '未匹配')"; return [ 'txn_date' => ['sort' => $t . '.txn_date', 'type' => 'date'], 'effective_account_short' => ['sort' => $effectiveAccount, 'type' => 'text'], 'detail' => ['sort' => $t . '.detail', 'type' => 'text'], 'source' => ['sort' => $t . '.source', 'type' => 'enum'], 'income' => ['sort' => $t . '.income', 'type' => 'number'], 'expense' => ['sort' => $t . '.expense', 'type' => 'number'], 'approval_no' => ['sort' => $t . '.approval_no', 'type' => 'text'], ]; } private function mappingHeaderColumns(): array { $mappingStatus = "CASE WHEN COALESCE(account_short, '') = '' THEN 'unmapped' ELSE 'mapped' END"; return [ 'source' => ['sort' => 'source', 'type' => 'enum'], 'source_value' => ['sort' => 'source_value', 'type' => 'text'], 'txn_count' => ['sort' => 'txn_count', 'type' => 'number'], 'income_sum' => ['sort' => 'income_sum', 'type' => 'number'], 'expense_sum' => ['sort' => 'expense_sum', 'type' => 'number'], 'account_short' => ['sort' => 'account_short', 'type' => 'text'], 'mapping_status' => ['sort' => $mappingStatus, 'type' => 'enum'], ]; } private function sourceHeaderColumns(): array { return [ 'source' => ['value' => 'source', 'type' => 'enum'], 'process_type' => ['value' => 'process_type', 'type' => 'text'], 'rows_count' => ['value' => 'rows_count', 'type' => 'number'], 'income' => ['value' => 'income', 'type' => 'number'], 'expense' => ['value' => 'expense', 'type' => 'number'], 'date_range' => ['value' => 'date_range', 'type' => 'text'], ]; } private function db() { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); } private function tableName(string $table): string { return $this->db()->getTablePrefix() . $table; } private function queryAlias(string $alias): string { return $this->db()->getTablePrefix() . $alias; } }