From 440483e32d481cc7755f92bcda152fa5fc33bd8e Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Fri, 6 Nov 2026 12:00:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6=E5=8A=A8=E8=B4=A6=E6=B1=87=E6=80=BB=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=20(#931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/FinanceLedgerPageData.php | 90 +++++++++++++++++-- .../pages/financial-ledger/ledger.blade.php | 29 ++++-- .../FinanceLedgerHeaderControlsTest.php | 39 ++++++++ 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index 24464ab5..7d76e117 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -8,6 +8,7 @@ use App\Utilities\FinanceLedgerAccountStatus; use App\Utilities\FinanceLedgerAccountShouldKeep; use App\Utilities\FinanceLedgerAccountType; use App\Utilities\FinanceLedgerAccess; +use Carbon\CarbonImmutable; use Illuminate\Database\Query\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; @@ -582,7 +583,7 @@ class FinanceLedgerPageData $this->applyTransactionFilters($query, $accountShort, $period); $headerColumns = $this->transactionHeaderColumns(); - $summary = $this->transactionSummaryMetrics($query); + $summary = $this->transactionSummaryMetrics($query, $accountShort, $period); $sorted = $this->applyLedgerHeaderSort($query, 'transactions', $headerColumns); if (! $sorted) { $query->orderByDesc('t.txn_date')->orderByDesc('t.id'); @@ -597,14 +598,16 @@ class FinanceLedgerPageData public function transactionSummary(): array { + $view = $this->transactionView(); + $period = $this->dateRange('txns'); $query = $this->transactionQuery(); $this->applyTransactionFilters( $query, - $this->transactionView()['account_short'], - $this->dateRange('txns'), + $view['account_short'], + $period, ); - return $this->transactionSummaryMetrics($query); + return $this->transactionSummaryMetrics($query, $view['account_short'], $period); } private function applyTransactionFilters( @@ -707,15 +710,92 @@ class FinanceLedgerPageData } } - private function transactionSummaryMetrics(Builder $query): array + private function transactionSummaryMetrics(Builder $query, ?string $accountShort, array $period): array { return [ 'count' => (clone $query)->count(), + 'previous_closing' => $this->transactionPreviousMonthClosing($accountShort, $period), 'income' => round((float) (clone $query)->sum('t.income'), 2), 'expense' => round((float) (clone $query)->sum('t.expense'), 2), + 'current_balance' => $this->transactionCurrentBalance($accountShort), ]; } + private function transactionCurrentBalance(?string $accountShort): ?float + { + $query = $this->db()->table('fin_accounts'); + if ($accountShort !== null) { + $query->where('account_short', $accountShort); + } else { + $account = trim((string) request('txns_account', '')); + if ($account !== '') { + $query->where('account_short', 'like', '%' . $account . '%'); + } + } + if (! (clone $query)->exists()) { + return null; + } + + return round((float) (clone $query)->sum('current_balance'), 2); + } + + private function transactionPreviousMonthClosing(?string $accountShort, array $period): ?float + { + $from = $period['from'] ?? null; + if ($from === null) { + return null; + } + + $monthStart = CarbonImmutable::parse($from)->startOfMonth(); + $monthEnd = $monthStart->endOfMonth(); + $previousMonthEnd = $monthStart->subDay(); + $base = $this->db()->table('fin_daily_flows as f'); + $this->applyTransactionFlowAccountScope($base, $accountShort); + + $balances = []; + $priorRows = (clone $base) + ->where('f.flow_date', '>=', $previousMonthEnd->toDateString()) + ->where('f.flow_date', '<=', $previousMonthEnd->toDateString()) + ->get(['f.account_short', 'f.closing_balance']); + foreach ($priorRows as $row) { + $account = (string) $row->account_short; + $balances[$account] = ($balances[$account] ?? 0.0) + (float) $row->closing_balance; + } + + $firstFlowDates = (clone $base) + ->where('f.flow_date', '>=', $monthStart->toDateString()) + ->where('f.flow_date', '<=', $monthEnd->toDateString()) + ->selectRaw('account_short, MIN(flow_date) AS first_flow_date') + ->groupBy('account_short'); + $openingRows = (clone $base) + ->joinSub($firstFlowDates, 'first_flow', function ($join): void { + $join->on('first_flow.account_short', '=', 'f.account_short') + ->on('first_flow.first_flow_date', '=', 'f.flow_date'); + }) + ->get(['f.account_short', 'f.opening_balance']); + foreach ($openingRows as $row) { + $account = (string) $row->account_short; + if (! array_key_exists($account, $balances)) { + $balances[$account] = (float) $row->opening_balance; + } + } + + return $balances === [] ? null : round(array_sum($balances), 2); + } + + private function applyTransactionFlowAccountScope(Builder $query, ?string $accountShort): void + { + if ($accountShort !== null) { + $query->where('f.account_short', $accountShort); + return; + } + + $account = trim((string) request('txns_account', '')); + if ($account !== '') { + $query->where('f.account_short', 'like', '%' . $account . '%'); + } + } + private function transactionQuery(): Builder { $effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort']; diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 4236294f..ac73ea02 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -354,6 +354,9 @@ .ledger-download-status { flex-basis:100%; color:var(--vip-success, #53793E); font-size:.68rem; font-weight:600; line-height:1.4; text-align:right; } .ledger-summary { display:flex; flex-wrap:wrap; gap:.65rem; margin-bottom:.85rem; } .ledger-summary__item { display:flex; flex:0 0 14rem; flex-direction:column; justify-content:center; gap:.2rem; width:14rem; min-width:14rem; height:3.4rem; padding:.6rem .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 2px rgba(16,24,40,.04); } + #ledger-panel-transactions [data-ledger-transaction-summary] { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); } + #ledger-panel-transactions [data-ledger-transaction-summary] .ledger-summary__item { flex:none; width:auto; min-width:0; } + @media (max-width: 560px) { #ledger-panel-transactions [data-ledger-transaction-summary] { grid-template-columns:repeat(2,minmax(0,1fr)); } } .ledger-summary__item--available { background:#f3f7ee; } .ledger-summary__item--blocked { background:#fff5f5; } .ledger-summary__item--link { color:inherit; text-decoration:none; transition:border-color .15s ease, box-shadow .15s ease; } @@ -1128,7 +1131,7 @@ ])
refresh重置
-
明细笔数{{ number_format((int) ($ledgerTransactionSummary['count'] ?? 0)) }}
收入合计{{ $money($ledgerTransactionSummary['income'] ?? 0) }}
支出合计{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}
净额{{ $money(($ledgerTransactionSummary['income'] ?? 0) - ($ledgerTransactionSummary['expense'] ?? 0)) }}
+
上月期末余额{{ ($ledgerTransactionSummary['previous_closing'] ?? null) === null ? '—' : $money($ledgerTransactionSummary['previous_closing']) }}
当月收入{{ $money($ledgerTransactionSummary['income'] ?? 0) }}
当月支出{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}
当前余额{{ ($ledgerTransactionSummary['current_balance'] ?? null) === null ? '—' : $money($ledgerTransactionSummary['current_balance']) }}
账户简称按“映射表 → 来源原始值 → 未匹配”解析;手工录入使用账户总表简称,并自动计入每日资金流水,不回写上游。拖动表头右侧边界可调整列宽,双击边界恢复默认。
@@ -1476,14 +1479,14 @@ throw new Error('明细页内容解析失败'); } - nextPanel.querySelectorAll('[data-ledger-transaction-summary-count], [data-ledger-transaction-summary-income], [data-ledger-transaction-summary-expense], [data-ledger-transaction-summary-net]').forEach((nextValue) => { - const selector = nextValue.hasAttribute('data-ledger-transaction-summary-count') - ? '[data-ledger-transaction-summary-count]' + nextPanel.querySelectorAll('[data-ledger-transaction-summary-previous-closing], [data-ledger-transaction-summary-income], [data-ledger-transaction-summary-expense], [data-ledger-transaction-summary-current-balance]').forEach((nextValue) => { + const selector = nextValue.hasAttribute('data-ledger-transaction-summary-previous-closing') + ? '[data-ledger-transaction-summary-previous-closing]' : nextValue.hasAttribute('data-ledger-transaction-summary-income') ? '[data-ledger-transaction-summary-income]' : nextValue.hasAttribute('data-ledger-transaction-summary-expense') ? '[data-ledger-transaction-summary-expense]' - : '[data-ledger-transaction-summary-net]'; + : '[data-ledger-transaction-summary-current-balance]'; const currentValue = transactionPanel.querySelector(selector); if (currentValue) currentValue.textContent = nextValue.textContent; }); @@ -2081,10 +2084,10 @@ }; const incomeInput = page.querySelector('[data-ledger-manual-income]'); const expenseInput = page.querySelector('[data-ledger-manual-expense]'); - const summaryCount = page.querySelector('[data-ledger-transaction-summary-count]'); + const summaryPreviousClosing = page.querySelector('[data-ledger-transaction-summary-previous-closing]'); const summaryIncome = page.querySelector('[data-ledger-transaction-summary-income]'); const summaryExpense = page.querySelector('[data-ledger-transaction-summary-expense]'); - const summaryNet = page.querySelector('[data-ledger-transaction-summary-net]'); + const summaryCurrentBalance = page.querySelector('[data-ledger-transaction-summary-current-balance]'); const metaTransactions = page.querySelector('[data-ledger-meta-transactions]'); const moneyFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, @@ -2222,10 +2225,18 @@ } appendManualRow(payload.row); - if (summaryCount) summaryCount.textContent = integerFormatter.format(payload.summary.count); + if (summaryPreviousClosing) { + summaryPreviousClosing.textContent = payload.summary.previous_closing === null + ? '—' + : moneyFormatter.format(payload.summary.previous_closing); + } if (summaryIncome) summaryIncome.textContent = moneyFormatter.format(payload.summary.income); if (summaryExpense) summaryExpense.textContent = moneyFormatter.format(payload.summary.expense); - if (summaryNet) summaryNet.textContent = moneyFormatter.format(payload.summary.income - payload.summary.expense); + if (summaryCurrentBalance) { + summaryCurrentBalance.textContent = payload.summary.current_balance === null + ? '—' + : moneyFormatter.format(payload.summary.current_balance); + } if (metaTransactions) metaTransactions.textContent = integerFormatter.format(payload.total_transactions); ledgerDependentDataStale = true; resetManualDraft(payload.next_idempotency_key); diff --git a/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php b/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php index 4903f96b..afe1bbff 100644 --- a/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php +++ b/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php @@ -181,6 +181,45 @@ class FinanceLedgerHeaderControlsTest extends FeatureTestCase } } + public function test_transaction_summary_uses_previous_month_close_and_current_month_cash_flow(): void + { + $connection = DB::connection('business_mirror'); + $connection->table('fin_daily_flows')->insert([ + 'flow_date' => '2026-09-30', + 'company_name' => '甲公司', + 'account_short' => '账户甲', + 'opening_balance' => 80, + 'inflow' => 10, + 'outflow' => 0, + 'closing_balance' => 90, + ]); + $connection->table('fin_transactions')->insert([ + 'txn_date' => '2026-10-20', + 'account_short' => '账户甲', + 'detail' => '当月支出', + 'income' => 0, + 'expense' => 5, + 'approval_no' => 'APR-支出', + 'source' => 'manual', + 'source_value' => null, + 'process_type' => '手工录入', + 'row_key' => 'period-summary-expense', + ]); + + $html = $this->loginAs()->get($this->pageUrl([ + 'txns_account' => '账户甲', + 'txns_from' => '2026-10-01', + 'txns_to' => '2026-10-31', + ]))->assertOk()->getContent(); + + $this->assertStringContainsString('>上月期末余额90.00', $html); + $this->assertStringContainsString('>当月收入10.00', $html); + $this->assertStringContainsString('>当月支出5.00', $html); + $this->assertStringContainsString('>当前余额100.00', $html); + $this->assertStringNotContainsString('data-ledger-transaction-summary-count', $html); + $this->assertStringNotContainsString('data-ledger-transaction-summary-net', $html); + } + public function test_shop_ledger_header_labels_and_column_order(): void { $html = $this->loginAs()->get($this->pageUrl())->assertOk()->getContent();