feat: 调整财务台账动账汇总口径 (#931)
This commit is contained in:
parent
63e5ff9348
commit
440483e32d
@ -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'];
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user