diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index 2c69a6a6..19cb3fa7 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -69,11 +69,11 @@ class FinanceLedgerPageData private function accounts(): array { - $query = $this->db()->table('fin_accounts'); + $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 (['company_name', 'account_short', 'account_no', 'owner'] as $column) { + foreach (['a.company_name', 'a.account_short', 'a.account_no', 'a.owner'] as $column) { $builder->orWhere($column, 'like', '%' . $keyword . '%'); } }); @@ -81,7 +81,7 @@ class FinanceLedgerPageData $accountShort = trim((string) request('accounts_short', '')); if ($accountShort !== '') { - $query->where('account_short', 'like', '%' . $accountShort . '%'); + $query->where('a.account_short', 'like', '%' . $accountShort . '%'); } $status = trim((string) request('accounts_status', '')); @@ -92,14 +92,14 @@ class FinanceLedgerPageData [FinanceLedgerAccountStatus::NORMAL], )); $query->whereNotIn( - 'account_short', + 'a.account_short', $this->db()->table('fin_account_statuses') ->select('account_short') ->whereIn('status', $nonNormalStatuses), ); } else { $query->whereIn( - 'account_short', + 'a.account_short', $this->db()->table('fin_account_statuses') ->select('account_short') ->where('status', $status), @@ -111,10 +111,10 @@ class FinanceLedgerPageData if ($region !== '') { if ($region === self::ACCOUNT_FILTER_EMPTY) { $query->where(function (Builder $builder): void { - $builder->whereNull('region')->orWhere('region', ''); + $builder->whereNull('a.region')->orWhere('a.region', ''); }); } else { - $query->where('region', $region); + $query->where('a.region', $region); } } @@ -123,15 +123,15 @@ class FinanceLedgerPageData $query->where(function (Builder $effectiveType) use ($accountType): void { $effectiveType->where(function (Builder $baseType) use ($accountType): void { $baseType->whereNotIn( - 'account_short', + '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('account_type')->orWhere('account_type', ''); + $blank->whereNull('a.account_type')->orWhere('a.account_type', ''); }); } else { - $baseType->where('account_type', $accountType); + $baseType->where('a.account_type', $accountType); } }); @@ -143,31 +143,39 @@ class FinanceLedgerPageData } else { $overrideType->where('account_type', $accountType); } - $effectiveType->orWhereIn('account_short', $overrideType); + $effectiveType->orWhereIn('a.account_short', $overrideType); }); } $balanceRange = trim((string) request('accounts_balance', '')); if ($balanceRange === '0-5') { - $query->where('current_balance', '>=', 0)->where('current_balance', '<', 50000); + $query->where('a.current_balance', '>=', 0)->where('a.current_balance', '<', 50000); } elseif ($balanceRange === '5-20') { - $query->where('current_balance', '>=', 50000)->where('current_balance', '<', 200000); + $query->where('a.current_balance', '>=', 50000)->where('a.current_balance', '<', 200000); } elseif ($balanceRange === '20-plus') { - $query->where('current_balance', '>=', 200000); + $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)->count(), - 'current_balance' => round((float) (clone $summaryQuery)->sum('current_balance'), 2), - 'balance' => round((float) (clone $summaryQuery)->sum('daily_available'), 2), - 'blocked' => round((float) (clone $summaryQuery)->sum('blocked_amount'), 2), + '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 - ->orderBy('company_name') - ->orderBy('account_short') ->paginate(100, ['*'], 'accounts_page') ->withQueryString(); @@ -213,13 +221,20 @@ class FinanceLedgerPageData }); } + $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->orderBy('company_name')->orderBy('shop_name') + 'rows' => $query ->paginate(100, ['*'], 'shops_page')->withQueryString(), 'summary' => $summary, ]; @@ -235,6 +250,8 @@ class FinanceLedgerPageData } } + $headerColumns = $this->flowHeaderColumns(); + $this->applyLedgerHeaderFilter($query, 'flows', $headerColumns); $summary = [ 'count' => (clone $query)->count(), 'closing' => round((float) (clone $query)->sum('closing_balance'), 2), @@ -242,8 +259,13 @@ class FinanceLedgerPageData '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->orderBy('flow_date')->orderBy('account_short') + 'rows' => $query ->paginate(100, ['*'], 'flows_page')->withQueryString(), 'summary' => $summary, ]; @@ -264,7 +286,8 @@ class FinanceLedgerPageData } $account = trim((string) request('txns_account', '')); if ($account !== '') { - $query->whereRaw("COALESCE(NULLIF(m.account_short,''), NULLIF(t.account_short,''), '未匹配') LIKE ?", ['%' . $account . '%']); + $effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort']; + $query->whereRaw($effectiveAccount . ' LIKE ?', ['%' . $account . '%']); } $from = trim((string) request('txns_from', '')); if ($from !== '') { @@ -279,14 +302,21 @@ class FinanceLedgerPageData $query->where('t.source', $source); } + $headerColumns = $this->transactionHeaderColumns(); + $this->applyLedgerHeaderFilter($query, 'transactions', $headerColumns); $summary = [ 'count' => (clone $query)->count(), 'income' => round((float) (clone $query)->sum('t.income'), 2), 'expense' => round((float) (clone $query)->sum('t.expense'), 2), ]; + $sorted = $this->applyLedgerHeaderSort($query, 'transactions', $headerColumns); + if (! $sorted) { + $query->orderByDesc('t.txn_date')->orderByDesc('t.id'); + } + return [ - 'rows' => $query->orderByDesc('t.txn_date')->orderByDesc('t.id') + 'rows' => $query ->paginate(100, ['*'], 'txns_page')->withQueryString(), 'summary' => $summary, ]; @@ -294,11 +324,29 @@ class FinanceLedgerPageData 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'); }) - ->selectRaw("t.*, COALESCE(NULLIF(m.account_short,''), NULLIF(t.account_short,''), '未匹配') AS effective_account_short"); + ->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 mapping(): array @@ -321,11 +369,17 @@ class FinanceLedgerPageData }); } + $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->orderByDesc('txn_count')->orderBy('source_value') + 'rows' => $query ->paginate(100, ['*'], 'mapping_page')->withQueryString(), 'total' => $total, 'mapped' => $mapped, @@ -337,14 +391,21 @@ class FinanceLedgerPageData private function sourceSummary(): array { - return $this->db()->table('fin_transactions') + $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 fn ($row): array => (array) $row) + ->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 @@ -404,8 +465,222 @@ class FinanceLedgerPageData ->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 [ + 'shop_name' => ['sort' => 'shop_name', 'type' => 'text'], + 'platform' => ['sort' => 'platform', '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; + } } diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index b64c3d88..630fe995 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -55,6 +55,96 @@ $ledgerPageUrl = route('business.pages.show', ['section' => 'financial-operations', 'page' => 'ledger']); $tabUrl = static fn (string $tab): string => $ledgerPageUrl . '?' . http_build_query(['tab' => $tab]); $sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单']; + $optionMap = static fn (array $values): array => array_combine($values, $values) ?: []; + $ledgerHeaderPreserve = []; + foreach (array_keys($tabs) as $tabKey) { + $ledgerHeaderPreserve[$tabKey] = collect(request()->query()) + ->except([ + 'tab', + $tabKey . '_page', + $tabKey . '_filter_column', + $tabKey . '_filter_value', + ]) + ->filter(static fn ($value): bool => is_scalar($value) || $value === null) + ->all(); + } + $ledgerTableHeaders = [ + 'accounts' => [ + ['company_name', '公司名称', 'text'], + ['account_short', '账户简称', 'text'], + ['account_type', '账户类型', 'enum', ['options' => $optionMap($accountTypeOptions)]], + ['account_status', '账户状态', 'enum', ['options' => $accountStatusOptions, 'headerClass' => 'ledger-status-column']], + ['region', '地区', 'text'], + ['account_no', '账号', 'text'], + ['bank_name', '开户行', 'text'], + ['bank_city_branch', '开户城市/网点', 'text'], + ['company_plan', '公司规划', 'enum', ['options' => $optionMap($accountPlanOptions), 'headerClass' => 'ledger-plan-column']], + ['account_should_keep', '账户是否保留', 'enum', ['options' => $optionMap($accountKeepOptions), 'headerClass' => 'ledger-keep-column']], + ['account_last4', '后4位', 'text'], + ['single_limit', '单笔限额(万元)', 'number'], + ['daily_limit', '日限额(万元)', 'number'], + ['monthly_limit', '月限额(万元)', 'number'], + ['yearly_limit', '年限额(万元)', 'number'], + ['current_balance', '当前余额(万元)', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['daily_available', '可用金额(万元)', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['blocked_amount', '不可用金额(万元)', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['daily_withdraw', '每日可出(万元)', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['clearing_days', '预计出清天数', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['raise_target', '提额目标(万元)', 'number'], + ['raise_status', '提额状态', 'text'], + ['raise_actions', '提额动作', 'text'], + ['done_actions', '已做动作', 'text'], + ['next_action', '下一步', 'text'], + ['remark', '备注', 'text'], + ['owner', '责任人', 'text'], + ], + 'shops' => [ + ['shop_name', '店铺名称', 'text'], + ['platform', '平台', 'text'], + ['company_name', '所属公司', 'text'], + ['account_short', '账户简称', 'text'], + ['status', '店铺状态', 'text'], + ['payout_account', '回款账户', 'text'], + ['shop_balance', '店铺余额', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['remark', '备注', 'text'], + ], + 'flows' => [ + ['flow_date', '日期', 'date'], + ['company_name', '公司', 'text'], + ['account_short', '账户简称', 'text'], + ['opening_balance', '期初余额', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['inflow', '当日入账', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['outflow', '当日出账', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['closing_balance', '期末余额', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['remark', '异常/备注', 'text'], + ], + 'transactions' => [ + ['txn_date', '日期', 'date'], + ['effective_account_short', '账户简称', 'text'], + ['detail', '明细', 'text'], + ['source', '来源', 'enum', ['options' => $sourceLabels]], + ['income', '收入', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['expense', '支出', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['approval_no', '审批编号/业务单号', 'text'], + ], + 'mapping' => [ + ['source', '来源', 'enum', ['options' => $sourceLabels]], + ['source_value', '原始值', 'text'], + ['txn_count', '笔数', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['income_sum', '收入合计', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['expense_sum', '支出合计', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['account_short', '映射账户简称', 'text'], + ['mapping_status', '状态', 'enum', ['options' => ['mapped' => '已映射', 'unmapped' => '未匹配']]], + ], + 'sources' => [ + ['source', '来源', 'enum', ['options' => $sourceLabels]], + ['process_type', '流程', 'text'], + ['rows_count', '明细笔数', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['income', '收入合计', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['expense', '支出合计', 'number', ['headerClass' => 'num', 'align' => 'end']], + ['date_range', '日期范围', 'text'], + ], + ]; @endphp @@ -130,6 +220,41 @@ .ledger-table { width:100%; min-width:82rem; border-collapse:separate; border-spacing:0; font-size:.72rem; } .ledger-table th, .ledger-table td { padding:.52rem .6rem; border-right:1px solid #e8e3da; border-bottom:1px solid #e8e3da; vertical-align:top; white-space:nowrap; } .ledger-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-ink, #171717); font-weight:700; text-align:left; } + .ledger-table th.is-menu-open { z-index:16; } + .ledger-header-menu { position:relative; display:flex; width:100%; min-width:0; align-items:center; gap:.18rem; } + .ledger-header-label { min-width:0; } + .ledger-header-menu > details { position:relative; flex:0 0 auto; } + .ledger-header-menu summary { display:inline-flex; width:1rem; height:1rem; align-items:center; justify-content:center; border-radius:.2rem; color:var(--vip-content-muted, #595959); cursor:pointer; list-style:none; } + .ledger-header-menu summary::-webkit-details-marker { display:none; } + .ledger-header-menu summary:hover, + .ledger-header-menu summary:focus-visible { color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 2px rgba(133,102,50,.16); } + .ledger-header-sort-icon { width:1rem; height:1rem; font-size:1rem; line-height:1; } + .ledger-header-cell.is-sorted .ledger-header-sort-icon, + .ledger-header-cell.is-filtered .ledger-header-sort-icon { color:var(--vip-content-accent-dark, #856632); } + .ledger-table th.num .ledger-header-menu { justify-content:flex-end; } + .ledger-header-menu__panel { position:absolute; top:calc(100% + .38rem); left:0; z-index:20; display:grid; width:14rem; gap:.55rem; padding:.55rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.45rem; background:#fff; box-shadow:0 4px 8px rgba(23,23,23,.10); color:var(--vip-content-ink, #171717); font-weight:400; text-align:left; white-space:normal; } + .ledger-header-menu__panel--end { left:auto; right:0; } + .ledger-header-filter { display:grid; gap:.4rem; } + .ledger-header-filter__field { display:grid; gap:.25rem; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.35; } + .ledger-header-filter__field input, + .ledger-header-filter__field select { width:100%; min-width:0; height:1.9rem; padding:0 .45rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.35rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-size:.7rem; } + .ledger-header-filter__field input:focus, + .ledger-header-filter__field select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 2px rgba(133,102,50,.16); } + .ledger-header-filter__actions, + .ledger-header-sort { display:flex; align-items:center; gap:.35rem; } + .ledger-header-filter__actions button, + .ledger-header-filter__actions a, + .ledger-header-sort a { display:inline-flex; min-height:1.7rem; align-items:center; justify-content:center; padding:0 .48rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.3rem; background:#fff; color:var(--vip-content-accent-dark, #856632); font:inherit; font-size:.68rem; font-weight:600; text-decoration:none; cursor:pointer; } + .ledger-header-filter__actions button { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-dark, #856632); color:#fff; } + .ledger-header-filter__actions button:hover, + .ledger-header-filter__actions a:hover, + .ledger-header-filter__actions a:focus-visible, + .ledger-header-sort a:hover, + .ledger-header-sort a:focus-visible { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-soft, #F7F0DE); outline:none; } + .ledger-header-filter__actions button:hover { background:#6A5129; } + .ledger-header-sort { padding-top:.45rem; border-top:1px solid #e8e3da; } + .ledger-header-sort > span { margin-right:auto; color:var(--vip-content-muted, #595959); font-size:.68rem; } + .ledger-header-sort a[aria-current="true"] { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-soft, #F7F0DE); } .ledger-table tr:hover td { background:#fcfaf4; } .ledger-accounts-table tbody td { padding-top:.2rem; padding-bottom:.2rem; vertical-align:middle; } .ledger-table th:first-child, .ledger-table td:first-child { position:sticky; left:0; z-index:2; background:#fff; } @@ -270,7 +395,22 @@
- + + + @foreach ($ledgerTableHeaders['accounts'] as $header) + @include('business.pages.financial-ledger.partials.table-header', [ + 'column' => $header[0], + 'label' => $header[1], + 'filterType' => $header[2], + 'options' => $header[3]['options'] ?? [], + 'headerClass' => $header[3]['headerClass'] ?? '', + 'align' => $header[3]['align'] ?? 'start', + 'tabKey' => 'accounts', + 'preserve' => $ledgerHeaderPreserve['accounts'], + ]) + @endforeach + + @forelse ($ledgerAccounts as $row) @@ -363,7 +503,20 @@
店铺数{{ number_format((int) ($ledgerShopSummary['count'] ?? 0)) }}
店铺余额合计{{ $money($ledgerShopSummary['balance'] ?? 0) }}
余额口径所属公司账户合计(待确认)
-
公司名称账户简称账户类型账户状态地区账号开户行开户城市/网点公司规划账户是否保留后4位单笔限额(万元)日限额(万元)月限额(万元)年限额(万元)当前余额(万元)可用金额(万元)不可用金额(万元)每日可出(万元)预计出清天数提额目标(万元)提额状态提额动作已做动作下一步备注责任人
+
店铺名称平台所属公司账户简称店铺状态回款账户店铺余额备注
+ @foreach ($ledgerTableHeaders['shops'] as $header) + @include('business.pages.financial-ledger.partials.table-header', [ + 'column' => $header[0], + 'label' => $header[1], + 'filterType' => $header[2], + 'options' => $header[3]['options'] ?? [], + 'headerClass' => $header[3]['headerClass'] ?? '', + 'align' => $header[3]['align'] ?? 'start', + 'tabKey' => 'shops', + 'preserve' => $ledgerHeaderPreserve['shops'], + ]) + @endforeach + @forelse ($ledgerShops as $row)@empty@endforelse
{{ $row->shop_name }}{{ $row->platform }}{{ $row->company_name }}{{ $row->account_short }}{{ $row->status }}{{ $row->payout_account }}{{ $money($row->shop_balance) }}{{ $row->remark }}
没有匹配的店铺数据。
共 {{ number_format($ledgerShops->total()) }} 条 · 第 {{ $ledgerShops->currentPage() }} / {{ $ledgerShops->lastPage() }} 页@if (!$ledgerShops->onFirstPage())上一页@endif @if ($ledgerShops->hasMorePages())下一页@endif
@@ -381,7 +534,20 @@
流水条数{{ number_format((int) ($ledgerFlowSummary['count'] ?? 0)) }}
期初余额({{ $ledgerOpeningDate }}){{ $money($meta['opening_sum'] ?? 0) }}
当期入账{{ $money($ledgerFlowSummary['inflow'] ?? 0) }}
当期出账{{ $money($ledgerFlowSummary['outflow'] ?? 0) }}
期末余额合计{{ $money($ledgerFlowSummary['closing'] ?? 0) }}
- +
日期公司账户简称期初余额当日入账当日出账期末余额异常/备注
+ @foreach ($ledgerTableHeaders['flows'] as $header) + @include('business.pages.financial-ledger.partials.table-header', [ + 'column' => $header[0], + 'label' => $header[1], + 'filterType' => $header[2], + 'options' => $header[3]['options'] ?? [], + 'headerClass' => $header[3]['headerClass'] ?? '', + 'align' => $header[3]['align'] ?? 'start', + 'tabKey' => 'flows', + 'preserve' => $ledgerHeaderPreserve['flows'], + ]) + @endforeach + @forelse ($ledgerFlows as $row)@empty@endforelse
{{ $date($row->flow_date) }}{{ $row->company_name }}{{ $row->account_short }}{{ $money($row->opening_balance) }}{{ $row->inflow > 0 ? $money($row->inflow) : '' }}{{ $row->outflow > 0 ? $money($row->outflow) : '' }}{{ $money($row->closing_balance) }}{{ $row->remark }}
当前筛选没有流水数据;期初日会为每个期初账户生成一行。
共 {{ number_format($ledgerFlows->total()) }} 条 · 第 {{ $ledgerFlows->currentPage() }} / {{ $ledgerFlows->lastPage() }} 页@if (!$ledgerFlows->onFirstPage())上一页@endif @if ($ledgerFlows->hasMorePages())下一页@endif
@@ -402,7 +568,20 @@
明细笔数{{ number_format((int) ($ledgerTransactionSummary['count'] ?? 0)) }}
收入合计{{ $money($ledgerTransactionSummary['income'] ?? 0) }}
支出合计{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}
净额{{ $money(($ledgerTransactionSummary['income'] ?? 0) - ($ledgerTransactionSummary['expense'] ?? 0)) }}
账户简称按“映射表 → 来源原始值 → 未匹配”解析;映射修改后无需重跑明细。
- +
日期账户简称明细来源收入支出审批编号/业务单号
+ @foreach ($ledgerTableHeaders['transactions'] as $header) + @include('business.pages.financial-ledger.partials.table-header', [ + 'column' => $header[0], + 'label' => $header[1], + 'filterType' => $header[2], + 'options' => $header[3]['options'] ?? [], + 'headerClass' => $header[3]['headerClass'] ?? '', + 'align' => $header[3]['align'] ?? 'start', + 'tabKey' => 'transactions', + 'preserve' => $ledgerHeaderPreserve['transactions'], + ]) + @endforeach + @forelse ($ledgerTransactions as $row)@empty@endforelse
{{ $date($row->txn_date) }}{{ $row->detail }}{{ $sourceLabels[$row->source] ?? $row->source }}{{ $row->income > 0 ? $money($row->income) : '' }}{{ $row->expense > 0 ? $money($row->expense) : '' }}{{ $row->approval_no }}
没有匹配的动账明细。
共 {{ number_format($ledgerTransactions->total()) }} 条 · 第 {{ $ledgerTransactions->currentPage() }} / {{ $ledgerTransactions->lastPage() }} 页@if (!$ledgerTransactions->onFirstPage())上一页@endif @if ($ledgerTransactions->hasMorePages())下一页@endif
@@ -442,7 +621,20 @@ @endif
- +
来源原始值笔数收入合计支出合计映射账户简称状态
+ @foreach ($ledgerTableHeaders['mapping'] as $header) + @include('business.pages.financial-ledger.partials.table-header', [ + 'column' => $header[0], + 'label' => $header[1], + 'filterType' => $header[2], + 'options' => $header[3]['options'] ?? [], + 'headerClass' => $header[3]['headerClass'] ?? '', + 'align' => $header[3]['align'] ?? 'start', + 'tabKey' => 'mapping', + 'preserve' => $ledgerHeaderPreserve['mapping'], + ]) + @endforeach + @forelse (($mapping['rows'] ?? collect()) as $row)@empty@endforelse
{{ $sourceLabels[$row->source] ?? $row->source }}{{ $row->source_value }}{{ number_format((int) $row->txn_count) }}{{ $money($row->income_sum) }}{{ $money($row->expense_sum) }}@if ($canEdit)@else{{ $row->account_short ?: '未匹配' }}@endif{{ $row->account_short ? '已映射' : '未匹配' }}
没有匹配的映射记录。
共 {{ number_format($mapping['total'] ?? 0) }} 条 · 第 {{ ($mapping['rows'] ?? null)?->currentPage() ?? 1 }} / {{ ($mapping['rows'] ?? null)?->lastPage() ?? 1 }} 页@if (($mapping['rows'] ?? null) && !$mapping['rows']->onFirstPage())上一页@endif @if (($mapping['rows'] ?? null) && $mapping['rows']->hasMorePages())下一页@endif
@@ -458,7 +650,20 @@ 店铺余额:当前按“所属公司名下所有账户最新期末余额合计”计算,待财务确认是否改为回款账户口径。
- +
来源流程明细笔数收入合计支出合计日期范围
+ @foreach ($ledgerTableHeaders['sources'] as $header) + @include('business.pages.financial-ledger.partials.table-header', [ + 'column' => $header[0], + 'label' => $header[1], + 'filterType' => $header[2], + 'options' => $header[3]['options'] ?? [], + 'headerClass' => $header[3]['headerClass'] ?? '', + 'align' => $header[3]['align'] ?? 'start', + 'tabKey' => 'sources', + 'preserve' => $ledgerHeaderPreserve['sources'], + ]) + @endforeach + @forelse (($ledgerSourceSummary ?? []) as $row)@empty@endforelse
{{ $sourceLabels[$row['source']] ?? $row['source'] }}{{ $row['process_type'] }}{{ number_format((int) $row['rows_count']) }}{{ $money($row['income']) }}{{ $money($row['expense']) }}{{ $date($row['first_date']) }} ~ {{ $date($row['last_date']) }}
尚未同步动账明细。
@@ -490,6 +695,26 @@ tabs.forEach((tab) => tab.addEventListener('click', () => showTab(tab.dataset.ledgerTab))); if (window.location.hash === '#mapping') showTab('mapping', false); + const headerDetails = Array.from(page.querySelectorAll('[data-ledger-header-details]')); + const closeHeaderMenus = (except = null) => { + headerDetails.forEach((details) => { + if (details !== except) details.open = false; + }); + }; + headerDetails.forEach((details) => { + details.addEventListener('toggle', () => { + details.closest('th')?.classList.toggle('is-menu-open', details.open); + if (details.open) closeHeaderMenus(details); + }); + }); + document.addEventListener('click', (event) => { + if (event.target.closest('[data-ledger-header-details]')) return; + closeHeaderMenus(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeHeaderMenus(); + }); + const toast = page.querySelector('[data-ledger-toast]'); const notify = (message, error = false, persistent = false) => { if (!toast) return; @@ -531,6 +756,9 @@ const accountColumnToggle = accountColumnPicker?.querySelector('[data-ledger-column-toggle]'); const accountColumnCount = accountColumnPicker?.querySelector('[data-ledger-column-count]'); const accountColumnKey = accountColumnPicker?.dataset.columnStorageKey || 'caiwu.financial-ledger.accounts.columns'; + const accountHeaderLabel = (header) => header.querySelector('[data-ledger-header-label]')?.textContent.trim() + || header.textContent.trim() + || '未命名字段'; const accountColumnHeaders = () => Array.from( accountPanel?.querySelectorAll('.ledger-accounts-table thead th') || [], ); @@ -689,7 +917,7 @@ initialAccountColumnHeaders.forEach((header, index) => { const key = initialAccountColumnKeys[index]; if (header.querySelector('[data-ledger-column-resizer]')) return; - const label = header.textContent.trim() || '未命名字段'; + const label = accountHeaderLabel(header); const resizer = document.createElement('span'); resizer.className = 'ledger-column-resizer'; resizer.dataset.ledgerColumnResizer = 'true'; @@ -767,7 +995,7 @@ checkbox.dataset.ledgerColumnCheck = 'true'; const text = document.createElement('span'); text.className = 'ledger-column-label'; - text.textContent = header.textContent.trim() || '未命名字段'; + text.textContent = accountHeaderLabel(header); label.append(handle, checkbox, text); accountColumnOptions.appendChild(label); }); diff --git a/resources/views/business/pages/financial-ledger/partials/table-header.blade.php b/resources/views/business/pages/financial-ledger/partials/table-header.blade.php new file mode 100644 index 00000000..3d2c7448 --- /dev/null +++ b/resources/views/business/pages/financial-ledger/partials/table-header.blade.php @@ -0,0 +1,118 @@ +@php + $tabKey = $tabKey ?? 'accounts'; + $filterType = $filterType ?? 'text'; + $headerClass = $headerClass ?? ''; + $options = $options ?? []; + $preserve = $preserve ?? []; + $align = $align ?? 'start'; + $sortParameter = $tabKey . '_sort'; + $directionParameter = $tabKey . '_direction'; + $filterColumnParameter = $tabKey . '_filter_column'; + $filterValueParameter = $tabKey . '_filter_value'; + $pageParameter = $tabKey . '_page'; + $sortKey = (string) request($sortParameter, ''); + $direction = strtolower((string) request($directionParameter, 'asc')); + $isSorted = $sortKey === $column; + $activeFilterColumn = (string) request($filterColumnParameter, ''); + $activeFilterValue = (string) request($filterValueParameter, ''); + $isFiltered = $activeFilterColumn === $column && $activeFilterValue !== ''; + $icon = $isSorted + ? ($direction === 'desc' ? 'arrow_downward' : 'arrow_upward') + : ($isFiltered ? 'filter_alt' : 'unfold_more'); + $ariaSort = ! $isSorted ? 'none' : ($direction === 'desc' ? 'descending' : 'ascending'); + $filterPlaceholder = match ($filterType) { + 'date' => '选择日期', + 'number' => '输入精确数值', + default => '输入筛选值', + }; +@endphp + + $align === 'end', + 'is-sorted' => $isSorted, + 'is-filtered' => $isFiltered, + ]) + data-ledger-header-field="{{ $column }}" + aria-sort="{{ $ariaSort }}" +> +
+ {{ $label }} +
+ + + +
$align === 'end', + ])> +
+ + + @foreach ($preserve as $preservedName => $preservedValue) + + @endforeach + +
+ + @if ($isFiltered) + 清除 + @endif +
+
+
+ 排序 + 升序 + 降序 +
+
+
+
+ diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 2cd580a7..7a9acf45 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -108,8 +108,13 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('导出录入模板') ->assertSee('data-account-template-download', false) ->assertSee('data-account-template-download-status', false) - ->assertSee('账户简称账户类型账户状态', false) - ->assertSee('公司规划账户是否保留后4位', false) + ->assertSee('data-ledger-header-field="account_short"', false) + ->assertSee('data-ledger-header-field="account_type"', false) + ->assertSee('data-ledger-header-field="account_status"', false) + ->assertSee('data-ledger-header-field="company_plan"', false) + ->assertSee('data-ledger-header-field="account_should_keep"', false) + ->assertSee('data-ledger-header-details', false) + ->assertSee('aria-label="账户状态排序和筛选"', false) ->assertSee('data-account-short="测试账户" data-account-should-keep="是"', false) ->assertSee('网商银行') ->assertSee('ledger-accounts-table-wrap', false) @@ -132,7 +137,9 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('href="' . $this->unavailableBalancesUrl() . '"', false) ->assertSee('查看账户异常待办') ->assertSee('正常计可用,异常计不可用') - ->assertSee('当前余额(万元)可用金额(万元)不可用金额(万元)', false) + ->assertSee('data-ledger-header-field="current_balance"', false) + ->assertSee('data-ledger-header-field="daily_available"', false) + ->assertSee('data-ledger-header-field="blocked_amount"', false) ->assertSee('data-ledger-summary-available', false) ->assertSee('data-account-current-balance="321.45"', false) ->assertSee('data-account-available-amount', false) diff --git a/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php b/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php new file mode 100644 index 00000000..7d333b83 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php @@ -0,0 +1,217 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + [ + 'company_name' => '甲公司', + 'account_short' => '账户甲', + 'account_type' => '公户', + 'region' => '上海', + 'company_plan' => '保留', + 'account_should_keep' => '是', + 'current_balance' => 100, + 'daily_available' => 80, + 'blocked_amount' => 20, + ], + [ + 'company_name' => '乙公司', + 'account_short' => '账户乙', + 'account_type' => '支付宝', + 'region' => '杭州', + 'company_plan' => '待注销', + 'account_should_keep' => '否', + 'current_balance' => 300, + 'daily_available' => 300, + 'blocked_amount' => 0, + ], + ]); + $connection->table('fin_shops')->insert([ + [ + 'shop_name' => '天猫店铺', + 'platform' => '天猫', + 'company_name' => '甲公司', + 'account_short' => '账户甲', + 'status' => '在营', + 'shop_balance' => 100, + ], + [ + 'shop_name' => '拼多多店铺', + 'platform' => '拼多多', + 'company_name' => '乙公司', + 'account_short' => '账户乙', + 'status' => '停业', + 'shop_balance' => 300, + ], + ]); + $connection->table('fin_daily_flows')->insert([ + [ + 'flow_date' => '2026-10-17', + 'company_name' => '甲公司', + 'account_short' => '账户甲', + 'opening_balance' => 100, + 'inflow' => 10, + 'outflow' => 0, + 'closing_balance' => 110, + ], + [ + 'flow_date' => '2026-10-18', + 'company_name' => '乙公司', + 'account_short' => '账户乙', + 'opening_balance' => 300, + 'inflow' => 0, + 'outflow' => 20, + 'closing_balance' => 280, + ], + ]); + $connection->table('fin_transactions')->insert([ + [ + 'txn_date' => '2026-10-17', + 'account_short' => '账户甲', + 'detail' => '甲笔流水', + 'income' => 10, + 'expense' => 0, + 'approval_no' => 'APR-甲', + 'source' => 'dingtalk', + 'source_value' => 'VAL-甲', + 'process_type' => '报销', + 'row_key' => 'header-filter-row-a', + ], + [ + 'txn_date' => '2026-10-18', + 'account_short' => '账户乙', + 'detail' => '乙笔流水', + 'income' => 0, + 'expense' => 20, + 'approval_no' => 'APR-乙', + 'source' => 'shop', + 'source_value' => 'VAL-乙', + 'process_type' => '提现', + 'row_key' => 'header-filter-row-b', + ], + ]); + $connection->table('fin_account_map')->insert([ + [ + 'source' => 'dingtalk', + 'source_value' => 'VAL-甲', + 'account_short' => '账户甲', + 'txn_count' => 1, + 'income_sum' => 10, + 'expense_sum' => 0, + ], + [ + 'source' => 'shop', + 'source_value' => 'VAL-乙', + 'account_short' => null, + 'txn_count' => 1, + 'income_sum' => 0, + 'expense_sum' => 20, + ], + ]); + } + + public function test_every_financial_ledger_table_has_a_header_menu(): void + { + $response = $this->loginAs()->get($this->pageUrl()); + $html = $response->assertOk()->getContent(); + + $this->assertSame(63, preg_match_all('/]*data-ledger-header-details/', $html)); + $this->assertSame(63, preg_match_all('/]*data-ledger-header-label/', $html)); + foreach (['accounts', 'shops', 'flows', 'transactions', 'mapping', 'sources'] as $tab) { + $this->assertStringContainsString('name="' . $tab . '_filter_value"', $html); + $this->assertStringContainsString($tab . '_sort=', $html); + } + } + + public function test_account_and_flow_headers_filter_and_sort_the_full_dataset(): void + { + $filtered = $this->loginAs()->get($this->pageUrl([ + 'accounts_filter_column' => 'company_name', + 'accounts_filter_value' => '乙公司', + ]))->assertOk(); + + $filtered->assertSee('data-account-short="账户乙"', false) + ->assertDontSee('data-account-short="账户甲"', false); + + $sortedAccounts = $this->loginAs()->get($this->pageUrl([ + 'accounts_sort' => 'current_balance', + 'accounts_direction' => 'desc', + ]))->assertOk()->getContent(); + $this->assertLessThan( + strpos($sortedAccounts, 'data-account-short="账户甲"'), + strpos($sortedAccounts, 'data-account-short="账户乙"'), + ); + + $flows = $this->loginAs()->get($this->pageUrl([ + 'accounts_filter_column' => 'company_name', + 'accounts_filter_value' => '乙公司', + 'shops_filter_column' => 'company_name', + 'shops_filter_value' => '乙公司', + 'flows_filter_column' => 'account_short', + 'flows_filter_value' => '账户乙', + ]))->assertOk(); + $flows->assertSee('>乙公司', false) + ->assertDontSee('>甲公司', false); + } + + public function test_shop_transaction_mapping_and_source_headers_filter_their_tables(): void + { + $this->loginAs()->get($this->pageUrl([ + 'shops_filter_column' => 'platform', + 'shops_filter_value' => '天猫', + ])) + ->assertOk() + ->assertSee('>天猫店铺', false) + ->assertDontSee('>拼多多店铺', false); + + $this->loginAs()->get($this->pageUrl([ + 'transactions_filter_column' => 'approval_no', + 'transactions_filter_value' => 'APR-乙', + 'transactions_sort' => 'expense', + 'transactions_direction' => 'desc', + ])) + ->assertOk() + ->assertSee('APR-乙') + ->assertDontSee('APR-甲'); + + $this->loginAs()->get($this->pageUrl([ + 'mapping_filter_column' => 'mapping_status', + 'mapping_filter_value' => 'unmapped', + ])) + ->assertOk() + ->assertSee('VAL-乙') + ->assertDontSee('VAL-甲'); + + $this->loginAs()->get($this->pageUrl([ + 'sources_filter_column' => 'source', + 'sources_filter_value' => 'shop', + ])) + ->assertOk() + ->assertSee('>提现', false) + ->assertDontSee('>报销', false); + } + + private function pageUrl(array $parameters = []): string + { + return route('business.pages.show', array_merge([ + 'company_id' => $this->company->id, + 'section' => 'financial-operations', + 'page' => 'ledger', + ], $parameters)); + } +}