feat(财务台账): 增加可筛选排序的表头 (#447)
This commit is contained in:
parent
9ab4e1e261
commit
ec3cd39413
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
@ -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)
|
||||
|
||||
217
tests/Feature/Business/FinanceLedgerHeaderControlsTest.php
Normal file
217
tests/Feature/Business/FinanceLedgerHeaderControlsTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user