feat: 调整财务台账动账汇总口径 (#931)

This commit is contained in:
shanshi 2026-11-06 12:00:53 +08:00
parent 63e5ff9348
commit 440483e32d
3 changed files with 144 additions and 14 deletions

View File

@ -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'];

View File

@ -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 @@
])
<div class="ledger-actions"><button class="ledger-btn primary" type="submit"><span class="material-icons-outlined">search</span>查询</button><a class="ledger-btn" href="{{ $transactionViewResetUrl }}"><span class="material-icons-outlined">refresh</span>重置</a></div>
</form>
<div class="ledger-summary" data-ledger-transaction-summary><div class="ledger-summary__item"><span>明细笔数</span><b data-ledger-transaction-summary-count>{{ number_format((int) ($ledgerTransactionSummary['count'] ?? 0)) }}</b></div><div class="ledger-summary__item"><span>收入合计</span><b data-ledger-transaction-summary-income>{{ $money($ledgerTransactionSummary['income'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>支出合计</span><b data-ledger-transaction-summary-expense>{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>净额</span><b data-ledger-transaction-summary-net>{{ $money(($ledgerTransactionSummary['income'] ?? 0) - ($ledgerTransactionSummary['expense'] ?? 0)) }}</b></div></div>
<div class="ledger-summary" data-ledger-transaction-summary><div class="ledger-summary__item"><span>上月期末余额</span><b data-ledger-transaction-summary-previous-closing>{{ ($ledgerTransactionSummary['previous_closing'] ?? null) === null ? '—' : $money($ledgerTransactionSummary['previous_closing']) }}</b></div><div class="ledger-summary__item"><span>当月收入</span><b data-ledger-transaction-summary-income>{{ $money($ledgerTransactionSummary['income'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>当月支出</span><b data-ledger-transaction-summary-expense>{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>当前余额</span><b data-ledger-transaction-summary-current-balance>{{ ($ledgerTransactionSummary['current_balance'] ?? null) === null ? '—' : $money($ledgerTransactionSummary['current_balance']) }}</b></div></div>
<div class="ledger-note">账户简称按“映射表 → 来源原始值 → 未匹配”解析;手工录入使用账户总表简称,并自动计入每日资金流水,不回写上游。拖动表头右侧边界可调整列宽,双击边界恢复默认。</div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:92rem"><thead><tr>
@ -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);

View File

@ -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('>上月期末余额</span><b data-ledger-transaction-summary-previous-closing>90.00</b>', $html);
$this->assertStringContainsString('>当月收入</span><b data-ledger-transaction-summary-income>10.00</b>', $html);
$this->assertStringContainsString('>当月支出</span><b data-ledger-transaction-summary-expense>5.00</b>', $html);
$this->assertStringContainsString('>当前余额</span><b data-ledger-transaction-summary-current-balance>100.00</b>', $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();