feat(财务台账): 增加可筛选排序的表头 (#447)

This commit is contained in:
DoDo 2026-10-21 21:13:49 +08:00
parent 9ab4e1e261
commit ec3cd39413
5 changed files with 884 additions and 39 deletions

View File

@ -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;
}
}

View File

@ -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
<meta name="csrf-token" content="{{ csrf_token() }}">
@ -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 @@
</div>
<div class="ledger-table-wrap ledger-accounts-table-wrap">
<table class="ledger-table ledger-accounts-table">
<thead><tr><th>公司名称</th><th>账户简称</th><th>账户类型</th><th class="ledger-status-column">账户状态</th><th>地区</th><th>账号</th><th>开户行</th><th>开户城市/网点</th><th class="ledger-plan-column">公司规划</th><th class="ledger-keep-column">账户是否保留</th><th>后4位</th><th>单笔限额(万元)</th><th>日限额(万元)</th><th>月限额(万元)</th><th>年限额(万元)</th><th class="num">当前余额(万元)</th><th class="num">可用金额(万元)</th><th class="num">不可用金额(万元)</th><th class="num">每日可出(万元)</th><th class="num">预计出清天数</th><th>提额目标(万元)</th><th>提额状态</th><th>提额动作</th><th>已做动作</th><th>下一步</th><th>备注</th><th>责任人</th></tr></thead>
<thead>
<tr>
@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
</tr>
</thead>
<tbody>
@forelse ($ledgerAccounts as $row)
<tr>
@ -363,7 +503,20 @@
</form>
<div class="ledger-summary"><div class="ledger-summary__item"><span>店铺数</span><b>{{ number_format((int) ($ledgerShopSummary['count'] ?? 0)) }}</b></div><div class="ledger-summary__item"><span>店铺余额合计</span><b>{{ $money($ledgerShopSummary['balance'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>余额口径</span><b>所属公司账户合计(待确认)</b></div></div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:56rem"><thead><tr><th>店铺名称</th><th>平台</th><th>所属公司</th><th>账户简称</th><th>店铺状态</th><th>回款账户</th><th class="num">店铺余额</th><th>备注</th></tr></thead>
<table class="ledger-table" style="min-width:56rem"><thead><tr>
@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
</tr></thead>
<tbody>@forelse ($ledgerShops as $row)<tr><td>{{ $row->shop_name }}</td><td>{{ $row->platform }}</td><td>{{ $row->company_name }}</td><td>{{ $row->account_short }}</td><td><span class="{{ $row->status === '在营' ? 'success' : 'muted' }}">{{ $row->status }}</span></td><td>{{ $row->payout_account }}</td><td class="num">{{ $money($row->shop_balance) }}</td><td>{{ $row->remark }}</td></tr>@empty<tr><td colspan="8"><div class="ledger-empty">没有匹配的店铺数据。</div></td></tr>@endforelse</tbody>
</table>
<div class="ledger-pagination"><span>共 {{ number_format($ledgerShops->total()) }} 条 · 第 {{ $ledgerShops->currentPage() }} / {{ $ledgerShops->lastPage() }} 页</span><span>@if (!$ledgerShops->onFirstPage())<a href="{{ $ledgerShops->previousPageUrl() }}">上一页</a>@endif @if ($ledgerShops->hasMorePages())<a href="{{ $ledgerShops->nextPageUrl() }}">下一页</a>@endif</span></div>
@ -381,7 +534,20 @@
</form>
<div class="ledger-summary"><div class="ledger-summary__item"><span>流水条数</span><b>{{ number_format((int) ($ledgerFlowSummary['count'] ?? 0)) }}</b></div><div class="ledger-summary__item"><span>期初余额({{ $ledgerOpeningDate }})</span><b>{{ $money($meta['opening_sum'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>当期入账</span><b>{{ $money($ledgerFlowSummary['inflow'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>当期出账</span><b>{{ $money($ledgerFlowSummary['outflow'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>期末余额合计</span><b>{{ $money($ledgerFlowSummary['closing'] ?? 0) }}</b></div></div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:62rem"><thead><tr><th>日期</th><th>公司</th><th>账户简称</th><th class="num">期初余额</th><th class="num">当日入账</th><th class="num">当日出账</th><th class="num">期末余额</th><th>异常/备注</th></tr></thead>
<table class="ledger-table" style="min-width:62rem"><thead><tr>
@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
</tr></thead>
<tbody>@forelse ($ledgerFlows as $row)<tr><td>{{ $date($row->flow_date) }}</td><td>{{ $row->company_name }}</td><td>{{ $row->account_short }}</td><td class="num">{{ $money($row->opening_balance) }}</td><td class="num">{{ $row->inflow > 0 ? $money($row->inflow) : '' }}</td><td class="num">{{ $row->outflow > 0 ? $money($row->outflow) : '' }}</td><td class="num">{{ $money($row->closing_balance) }}</td><td>{{ $row->remark }}</td></tr>@empty<tr><td colspan="8"><div class="ledger-empty">当前筛选没有流水数据;期初日会为每个期初账户生成一行。</div></td></tr>@endforelse</tbody>
</table>
<div class="ledger-pagination"><span>共 {{ number_format($ledgerFlows->total()) }} 条 · 第 {{ $ledgerFlows->currentPage() }} / {{ $ledgerFlows->lastPage() }} 页</span><span>@if (!$ledgerFlows->onFirstPage())<a href="{{ $ledgerFlows->previousPageUrl() }}">上一页</a>@endif @if ($ledgerFlows->hasMorePages())<a href="{{ $ledgerFlows->nextPageUrl() }}">下一页</a>@endif</span></div>
@ -402,7 +568,20 @@
<div class="ledger-summary"><div class="ledger-summary__item"><span>明细笔数</span><b>{{ number_format((int) ($ledgerTransactionSummary['count'] ?? 0)) }}</b></div><div class="ledger-summary__item"><span>收入合计</span><b>{{ $money($ledgerTransactionSummary['income'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>支出合计</span><b>{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>净额</span><b>{{ $money(($ledgerTransactionSummary['income'] ?? 0) - ($ledgerTransactionSummary['expense'] ?? 0)) }}</b></div></div>
<div class="ledger-note">账户简称按“映射表 → 来源原始值 → 未匹配”解析;映射修改后无需重跑明细。</div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:66rem"><thead><tr><th>日期</th><th>账户简称</th><th>明细</th><th>来源</th><th class="num">收入</th><th class="num">支出</th><th>审批编号/业务单号</th></tr></thead>
<table class="ledger-table" style="min-width:66rem"><thead><tr>
@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
</tr></thead>
<tbody>@forelse ($ledgerTransactions as $row)<tr><td>{{ $date($row->txn_date) }}</td><td class="{{ $row->effective_account_short === '未匹配' ? 'danger' : '' }}">{{ $row->effective_account_short }}</td><td class="wrap">{{ $row->detail }}</td><td><span class="ledger-source-tag">{{ $sourceLabels[$row->source] ?? $row->source }}</span></td><td class="num">{{ $row->income > 0 ? $money($row->income) : '' }}</td><td class="num">{{ $row->expense > 0 ? $money($row->expense) : '' }}</td><td>{{ $row->approval_no }}</td></tr>@empty<tr><td colspan="7"><div class="ledger-empty">没有匹配的动账明细。</div></td></tr>@endforelse</tbody>
</table>
<div class="ledger-pagination"><span>共 {{ number_format($ledgerTransactions->total()) }} 条 · 第 {{ $ledgerTransactions->currentPage() }} / {{ $ledgerTransactions->lastPage() }} 页</span><span>@if (!$ledgerTransactions->onFirstPage())<a href="{{ $ledgerTransactions->previousPageUrl() }}">上一页</a>@endif @if ($ledgerTransactions->hasMorePages())<a href="{{ $ledgerTransactions->nextPageUrl() }}">下一页</a>@endif</span></div>
@ -442,7 +621,20 @@
@endif
</div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:64rem"><thead><tr><th>来源</th><th>原始值</th><th class="num">笔数</th><th class="num">收入合计</th><th class="num">支出合计</th><th>映射账户简称</th><th>状态</th></tr></thead>
<table class="ledger-table" style="min-width:64rem"><thead><tr>
@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
</tr></thead>
<tbody>@forelse (($mapping['rows'] ?? collect()) as $row)<tr data-mapping-row data-id="{{ $row->id }}"><td><span class="ledger-source-tag">{{ $sourceLabels[$row->source] ?? $row->source }}</span></td><td>{{ $row->source_value }}</td><td class="num">{{ number_format((int) $row->txn_count) }}</td><td class="num">{{ $money($row->income_sum) }}</td><td class="num">{{ $money($row->expense_sum) }}</td><td>@if ($canEdit)<input class="ledger-map-input" list="ledger-account-list" value="{{ $row->account_short }}" placeholder="选择账户简称" data-mapping-input data-id="{{ $row->id }}" data-url="{{ route('business.financial-operations.ledger.mapping.update', ['id' => $row->id]) }}">@else{{ $row->account_short ?: '未匹配' }}@endif</td><td data-mapping-state class="{{ $row->account_short ? 'success' : 'danger' }}">{{ $row->account_short ? '已映射' : '未匹配' }}</td></tr>@empty<tr><td colspan="7"><div class="ledger-empty">没有匹配的映射记录。</div></td></tr>@endforelse</tbody>
</table>
<div class="ledger-pagination"><span>共 {{ number_format($mapping['total'] ?? 0) }} 条 · 第 {{ ($mapping['rows'] ?? null)?->currentPage() ?? 1 }} / {{ ($mapping['rows'] ?? null)?->lastPage() ?? 1 }} 页</span><span>@if (($mapping['rows'] ?? null) && !$mapping['rows']->onFirstPage())<a href="{{ $mapping['rows']->previousPageUrl() }}">上一页</a>@endif @if (($mapping['rows'] ?? null) && $mapping['rows']->hasMorePages())<a href="{{ $mapping['rows']->nextPageUrl() }}">下一页</a>@endif</span></div>
@ -458,7 +650,20 @@
<b>店铺余额:</b>当前按“所属公司名下所有账户最新期末余额合计”计算,待财务确认是否改为回款账户口径。
</div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:55rem"><thead><tr><th>来源</th><th>流程</th><th class="num">明细笔数</th><th class="num">收入合计</th><th class="num">支出合计</th><th>日期范围</th></tr></thead>
<table class="ledger-table" style="min-width:55rem"><thead><tr>
@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
</tr></thead>
<tbody>@forelse (($ledgerSourceSummary ?? []) as $row)<tr><td><span class="ledger-source-tag">{{ $sourceLabels[$row['source']] ?? $row['source'] }}</span></td><td>{{ $row['process_type'] }}</td><td class="num">{{ number_format((int) $row['rows_count']) }}</td><td class="num">{{ $money($row['income']) }}</td><td class="num">{{ $money($row['expense']) }}</td><td>{{ $date($row['first_date']) }} ~ {{ $date($row['last_date']) }}</td></tr>@empty<tr><td colspan="6"><div class="ledger-empty">尚未同步动账明细。</div></td></tr>@endforelse</tbody>
</table>
</div>
@ -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);
});

View File

@ -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
<th
scope="col"
@class([
$headerClass,
'ledger-header-cell',
'is-number' => $align === 'end',
'is-sorted' => $isSorted,
'is-filtered' => $isFiltered,
])
data-ledger-header-field="{{ $column }}"
aria-sort="{{ $ariaSort }}"
>
<div class="ledger-header-menu">
<span class="ledger-header-label" data-ledger-header-label>{{ $label }}</span>
<details data-ledger-header-details>
<summary aria-label="{{ $label }}排序和筛选" title="{{ $label }}排序和筛选">
<span class="material-icons-outlined ledger-header-sort-icon" aria-hidden="true">{{ $icon }}</span>
</summary>
<div @class([
'ledger-header-menu__panel',
'ledger-header-menu__panel--end' => $align === 'end',
])>
<form method="GET" action="{{ request()->url() }}" class="ledger-header-filter" aria-label="筛选{{ $label }}">
<input type="hidden" name="tab" value="{{ $tabKey }}">
<input type="hidden" name="{{ $filterColumnParameter }}" value="{{ $column }}">
@foreach ($preserve as $preservedName => $preservedValue)
<input
type="hidden"
name="{{ $preservedName }}"
value="{{ is_bool($preservedValue) ? (int) $preservedValue : $preservedValue }}"
>
@endforeach
<label class="ledger-header-filter__field">
<span>筛选</span>
@if ($options !== [])
<select name="{{ $filterValueParameter }}" aria-label="{{ $label }}筛选值">
<option value="">全部</option>
@foreach ($options as $optionValue => $optionLabel)
<option value="{{ $optionValue }}" @selected((string) $optionValue === $activeFilterValue)>{{ $optionLabel }}</option>
@endforeach
</select>
@else
<input
type="{{ in_array($filterType, ['date', 'number'], true) ? $filterType : 'search' }}"
name="{{ $filterValueParameter }}"
value="{{ $isFiltered ? $activeFilterValue : '' }}"
placeholder="{{ $filterPlaceholder }}"
@if ($filterType === 'number') step="any" @endif
aria-label="{{ $label }}筛选值"
>
@endif
</label>
<div class="ledger-header-filter__actions">
<button type="submit">应用</button>
@if ($isFiltered)
<a href="{{ request()->fullUrlWithQuery([
'tab' => $tabKey,
$filterColumnParameter => null,
$filterValueParameter => null,
$pageParameter => null,
]) }}">清除</a>
@endif
</div>
</form>
<div class="ledger-header-sort" aria-label="{{ $label }}排序">
<span>排序</span>
<a
href="{{ request()->fullUrlWithQuery([
'tab' => $tabKey,
$sortParameter => $column,
$directionParameter => 'asc',
$pageParameter => null,
]) }}"
@if ($isSorted && $direction === 'asc') aria-current="true" @endif
>升序</a>
<a
href="{{ request()->fullUrlWithQuery([
'tab' => $tabKey,
$sortParameter => $column,
$directionParameter => 'desc',
$pageParameter => null,
]) }}"
@if ($isSorted && $direction === 'desc') aria-current="true" @endif
>降序</a>
</div>
</div>
</details>
</div>
</th>

View File

@ -108,8 +108,13 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertSee('导出录入模板')
->assertSee('data-account-template-download', false)
->assertSee('data-account-template-download-status', false)
->assertSee('<th>账户简称</th><th>账户类型</th><th class="ledger-status-column">账户状态</th>', false)
->assertSee('<th class="ledger-plan-column">公司规划</th><th class="ledger-keep-column">账户是否保留</th><th>后4位</th>', 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('<th class="num">当前余额(万元)</th><th class="num">可用金额(万元)</th><th class="num">不可用金额(万元)</th>', 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)

View File

@ -0,0 +1,217 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerHeaderControlsTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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('/<details\b[^>]*data-ledger-header-details/', $html));
$this->assertSame(63, preg_match_all('/<span\b[^>]*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('>乙公司</td>', false)
->assertDontSee('>甲公司</td>', 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('>天猫店铺</td>', false)
->assertDontSee('>拼多多店铺</td>', 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('>提现</td>', false)
->assertDontSee('>报销</td>', 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));
}
}