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 @@
- | 公司名称 | 账户简称 | 账户类型 | 账户状态 | 地区 | 账号 | 开户行 | 开户城市/网点 | 公司规划 | 账户是否保留 | 后4位 | 单笔限额(万元) | 日限额(万元) | 月限额(万元) | 年限额(万元) | 当前余额(万元) | 可用金额(万元) | 不可用金额(万元) | 每日可出(万元) | 预计出清天数 | 提额目标(万元) | 提额状态 | 提额动作 | 已做动作 | 下一步 | 备注 | 责任人 |
+
+
+ @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) }}
余额口径所属公司账户合计(待确认)
-
| 店铺名称 | 平台 | 所属公司 | 账户简称 | 店铺状态 | 回款账户 | 店铺余额 | 备注 |
+
+ @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)| {{ $row->shop_name }} | {{ $row->platform }} | {{ $row->company_name }} | {{ $row->account_short }} | {{ $row->status }} | {{ $row->payout_account }} | {{ $money($row->shop_balance) }} | {{ $row->remark }} |
@empty没有匹配的店铺数据。 |
@endforelse
@@ -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)| {{ $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 }} |
@empty当前筛选没有流水数据;期初日会为每个期初账户生成一行。 |
@endforelse
@@ -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)| {{ $date($row->txn_date) }} | {{ $row->effective_account_short }} | {{ $row->detail }} | {{ $sourceLabels[$row->source] ?? $row->source }} | {{ $row->income > 0 ? $money($row->income) : '' }} | {{ $row->expense > 0 ? $money($row->expense) : '' }} | {{ $row->approval_no }} |
@empty没有匹配的动账明细。 |
@endforelse
@@ -442,7 +621,20 @@
@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)| {{ $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']) }} |
@empty尚未同步动账明细。 |
@endforelse
@@ -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 }}"
+>
+
+ |
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));
+ }
+}