diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 98f2631f..c4b2c8d6 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -1390,12 +1390,12 @@ class FinanceLedger extends Controller { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改每日资金流水。'); + $openingDate = (string) config('finance_ledger.opening_date', '2026-10-17'); $data = $request->validate([ - 'flow_date' => ['required', 'date_format:Y-m-d'], + 'flow_date' => ['required', 'date_format:Y-m-d', 'after_or_equal:' . $openingDate, 'before_or_equal:today'], 'account_short' => ['required', 'string', 'max:100'], ]); - $flow = $editor->existingFlow((string) $data['flow_date'], trim((string) $data['account_short'])); - abort_if($flow === null, 404); + $flow = $editor->editableFlow((string) $data['flow_date'], trim((string) $data['account_short'])); return response()->json([ 'ok' => true, diff --git a/modules/EcommerceFinance/Services/FinanceLedgerFlowEditor.php b/modules/EcommerceFinance/Services/FinanceLedgerFlowEditor.php index e6acedbc..ea127097 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerFlowEditor.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerFlowEditor.php @@ -175,6 +175,58 @@ class FinanceLedgerFlowEditor ]; } + /** + * 汇总页可以在尚无每日流水时直接选择日期建立手动流水。 + * + * @return array + */ + public function editableFlow(string $flowDate, string $accountShort): array + { + $date = $this->date($flowDate); + $accountShort = $this->text($accountShort); + $existing = $this->existingFlow($date, $accountShort); + if ($existing !== null) { + return $existing; + } + + $account = $this->connection->table('fin_accounts') + ->where('account_short', $accountShort) + ->orderBy('id') + ->first(['account_short', 'company_name']); + if ($account === null) { + throw ValidationException::withMessages([ + 'account_short' => '账户简称不存在,请从账户总表中选择。', + ]); + } + $latest = $this->connection->table('fin_daily_flows') + ->where('account_short', $accountShort) + ->where('flow_date', '<', $date) + ->orderByDesc('flow_date') + ->orderByDesc('id') + ->first(['closing_balance']); + $openingBalance = $latest === null + ? round((float) ($this->connection->table('fin_account_opening') + ->where('account_short', $accountShort) + ->where('opening_date', '<=', $date) + ->orderByDesc('opening_date') + ->value('opening_balance') ?? 0), 2) + : round((float) $latest->closing_balance, 2); + + return [ + 'id' => null, + 'flow_date' => $date, + 'company_name' => (string) ($account->company_name ?? ''), + 'account_short' => (string) $account->account_short, + 'opening_balance' => $openingBalance, + 'inflow' => 0.0, + 'outflow' => 0.0, + 'closing_balance' => $openingBalance, + 'remark' => '新建手动流水', + 'manual_override' => false, + 'updated_by' => null, + ]; + } + private function date(mixed $value): string { $text = trim((string) $value); diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index aa61bf40..bc0ab671 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -101,6 +101,14 @@ $flowViewQuery, ['tab' => 'flows', 'flows_view' => $view], )); + $flowSummaryEditDate = now()->toDateString(); + if (($flowPeriod['to'] ?? null) !== null && $flowSummaryEditDate > $flowPeriod['to']) { + $flowSummaryEditDate = $flowPeriod['to']; + } + if (($flowPeriod['from'] ?? null) !== null && $flowSummaryEditDate < $flowPeriod['from']) { + $flowSummaryEditDate = $flowPeriod['from']; + } + $flowSummaryEditDate = min($flowSummaryEditDate, now()->toDateString()); $ledgerHeaderPreserve = []; foreach (array_keys($tabs) as $tabKey) { $ledgerHeaderPreserve[$tabKey] = collect(request()->query()) @@ -568,6 +576,9 @@ .ledger-transaction-approval-actions > span { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .ledger-row-edit.is-delete { color:var(--vip-danger, #B80000); } .ledger-row-edit.is-delete:hover, .ledger-row-edit.is-delete:focus-visible { border-color:var(--vip-danger, #B80000); background:#fff5f5; } + .ledger-row-edit--text { display:inline-flex; width:auto; min-width:4.8rem; gap:.2rem; padding:0 .45rem; color:var(--vip-content-accent-dark, #856632); font-size:.68rem; font-weight:600; text-decoration:none; white-space:nowrap; } + #ledger-flow-date-edit:not([readonly]) { border-color:var(--vip-content-accent, #B9954F); background:#fffdf7; } + #ledger-flow-date-edit:not([readonly]):focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } .ledger-manual-badge { display:inline-flex; margin-left:.25rem; padding:.08rem .28rem; border-radius:.25rem; background:#fff8eb; color:var(--vip-warning, #935F07); font-size:.58rem; font-weight:700; vertical-align:middle; } .ledger-custom-column-head { display:flex; align-items:center; gap:.3rem; min-width:0; } .ledger-custom-column-head strong { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } @@ -602,6 +613,7 @@ @media (max-width: 760px) { .ledger-manual-custom-fields { grid-template-columns:minmax(0,1fr); } } .ledger-flow-equation { grid-column:1 / -1; margin:0; padding:.55rem .65rem; border-radius:.4rem; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-muted, #595959); font-size:.66rem; line-height:1.5; } .ledger-flow-edit-actions { display:flex; align-items:center; gap:.5rem; margin-right:auto; } + .ledger-flow-edit-actions [hidden] { display:none !important; } .ledger-flow-edit-actions .ledger-btn { color:var(--vip-warning, #935F07); } .ledger-mono { font-variant-numeric:tabular-nums; } @media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } .ledger-edit-dialog .ledger-company-form__body { grid-template-columns:repeat(2, minmax(0, 1fr)); } } @@ -854,37 +866,6 @@ - -
- @csrf - - -
-
-

编辑每日资金流水

-

保存后按手填数据重算该账户后续流水和当前余额;恢复自动计算会删除本日手动覆盖。

-
- -
-
-
-
-
-
-
-
-
-

期末余额按“期初余额 + 当日入账 - 当日出账”自动计算,保存时会再次校验。

-
- -
-
-
- - -
-
-
@endif
账户数{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}
@@ -1114,6 +1095,9 @@ 'preserve' => $ledgerHeaderPreserve['flows'], ]) @endforeach + @if ($flowView === 'summary') + 操作 + @endif @if ($flowView === 'daily') @@ -1124,9 +1108,9 @@ @endforelse @else @forelse ($ledgerFlows as $row) - {{ $row->account_short }}{{ $row->bank_affiliation ?: '—' }}{{ $row->company_name }}{{ $row->opening_balance === null ? '—' : $money($row->opening_balance) }}{{ $row->inflow > 0 ? $money($row->inflow) : '' }}{{ $row->outflow > 0 ? $money($row->outflow) : '' }}{{ $row->closing_balance === null ? '—' : $money($row->closing_balance) }}{{ number_format((int) $row->activity_days) }}{{ $row->remark }} + {{ $row->account_short }}{{ $row->bank_affiliation ?: '—' }}{{ $row->company_name }}{{ $row->opening_balance === null ? '—' : $money($row->opening_balance) }}{{ $row->inflow > 0 ? $money($row->inflow) : '' }}{{ $row->outflow > 0 ? $money($row->outflow) : '' }}{{ $row->closing_balance === null ? '—' : $money($row->closing_balance) }}{{ number_format((int) $row->activity_days) }}{{ $row->remark }}@if ($canEditAccounts)@else—@endif @empty -
没有匹配的账户。
+
没有匹配的账户。
@endforelse @endif @@ -1441,6 +1425,40 @@
+ @if ($canEditAccounts) + +
+ @csrf + + +
+
+

编辑每日资金流水

+

保存后按手填数据重算该账户后续流水和当前余额;恢复自动计算会删除本日手动覆盖。

+
+ +
+
+
+
+
+
+
+
+
+

期末余额按“期初余额 + 当日入账 - 当日出账”自动计算,保存时会再次校验。

+
+ +
+
+
+ + +
+
+
+ @endif + @if ($canEditTransactions)
@@ -3270,11 +3288,23 @@ const flowEditError = page.querySelector('[data-ledger-flow-error]'); const flowEditSave = page.querySelector('[data-ledger-flow-save]'); const flowEditReset = page.querySelector('[data-ledger-flow-reset]'); + const flowEditTitle = page.querySelector('[data-ledger-flow-title]'); + const flowEditHelp = page.querySelector('[data-ledger-flow-help]'); + const flowDateInput = page.querySelector('[data-ledger-flow-date]'); + const flowDateValue = flowEditForm?.querySelector('[data-ledger-flow-date-value]'); + const flowAccountValue = flowEditForm?.querySelector('[data-ledger-flow-account-value]'); + const flowAccountInput = page.querySelector('[data-ledger-flow-account]'); + const flowModeInput = page.querySelector('[data-ledger-flow-mode]'); + const flowRemarkInput = page.querySelector('[data-ledger-flow-remark]'); const flowOpening = page.querySelector('[data-ledger-flow-opening]'); const flowInflow = page.querySelector('[data-ledger-flow-inflow]'); const flowOutflow = page.querySelector('[data-ledger-flow-outflow]'); const flowClosing = page.querySelector('[data-ledger-flow-closing]'); let flowResetUrl = ''; + let activeFlowEditButton = null; + let flowDateLoadId = 0; + let flowEditLoadedDate = ''; + const defaultFlowEditHelp = flowEditHelp?.textContent || ''; const updateFlowClosing = () => { if (!flowClosing) return; const opening = Number(flowOpening?.value || 0); @@ -3283,44 +3313,93 @@ flowClosing.value = (opening + inflow - outflow).toFixed(2); }; [flowOpening, flowInflow, flowOutflow].forEach((input) => input?.addEventListener('input', updateFlowClosing)); + const setFlowEditMode = (button) => { + const summaryMode = button.hasAttribute('data-flow-summary-edit'); + if (flowDateInput) { + flowDateInput.readOnly = !summaryMode; + flowDateInput.title = summaryMode + ? '可修改日期;没有流水的日期会新建手动流水' + : '从按日流水进入时,日期不可修改'; + } + if (flowEditTitle) { + flowEditTitle.textContent = summaryMode ? '编辑账户每日流水' : '编辑每日资金流水'; + } + if (flowEditHelp) { + flowEditHelp.textContent = summaryMode + ? '可选择日期后编辑当日流水;目标日期尚无流水时会新建手动流水,保存后统一回算账户余额和汇总。' + : defaultFlowEditHelp; + } + }; + const flowEditRequestUrl = (button, date) => { + const url = new URL(button.dataset.editUrl, window.location.origin); + url.searchParams.set('flow_date', date); + url.searchParams.set('account_short', button.dataset.flowAccountShort || ''); + return url.toString(); + }; + const loadFlowForDate = async (button, date) => { + const requestId = ++flowDateLoadId; + const response = await fetch(flowEditRequestUrl(button, date), { + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const firstError = Object.values(payload.errors || {})[0]?.[0]; + throw new Error(firstError || payload.message || '流水读取失败'); + } + return { requestId, flow: payload.flow || {} }; + }; + const renderFlowEdit = (button, flow) => { + if (!flowEditForm) return; + flowEditForm.action = button.dataset.saveUrl; + flowResetUrl = button.dataset.resetUrl; + flowEditLoadedDate = flow.flow_date || ''; + if (flowDateValue) flowDateValue.value = flow.flow_date || ''; + if (flowAccountValue) flowAccountValue.value = flow.account_short || ''; + if (flowDateInput) flowDateInput.value = flow.flow_date || ''; + if (flowAccountInput) flowAccountInput.value = flow.account_short || ''; + if (flowModeInput) flowModeInput.value = flow.manual_override ? '手动覆盖' : '自动计算'; + if (flowOpening) flowOpening.value = Number(flow.opening_balance || 0).toFixed(2); + if (flowInflow) flowInflow.value = Number(flow.inflow || 0).toFixed(2); + if (flowOutflow) flowOutflow.value = Number(flow.outflow || 0).toFixed(2); + if (flowRemarkInput) flowRemarkInput.value = flow.remark || ''; + updateFlowClosing(); + if (flowEditReset) flowEditReset.hidden = !flow.manual_override; + if (flowEditError) { + flowEditError.hidden = true; + flowEditError.textContent = ''; + } + }; const closeFlowEditDialog = () => { + flowDateLoadId += 1; + activeFlowEditButton = null; + flowEditLoadedDate = ''; if (flowEditDialog?.open) flowEditDialog.close(); }; page.querySelectorAll('[data-ledger-flow-edit]').forEach((button) => { button.addEventListener('click', async () => { if (!flowEditDialog || !flowEditForm) return; button.disabled = true; + activeFlowEditButton = button; + setFlowEditMode(button); + const requestedDate = button.dataset.flowDate || flowDateInput?.value || ''; + if (flowDateInput) flowDateInput.value = requestedDate; + if (flowDateValue) flowDateValue.value = requestedDate; + if (flowEditReset) flowEditReset.hidden = true; try { - const response = await fetch(button.dataset.editUrl, { - credentials: 'same-origin', - headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(payload.message || '流水读取失败'); - const flow = payload.flow || {}; - flowEditForm.action = button.dataset.saveUrl; - flowResetUrl = button.dataset.resetUrl; - flowEditForm.querySelector('[data-ledger-flow-date-value]').value = flow.flow_date || ''; - flowEditForm.querySelector('[data-ledger-flow-account-value]').value = flow.account_short || ''; - page.querySelector('[data-ledger-flow-date]').value = flow.flow_date || ''; - page.querySelector('[data-ledger-flow-account]').value = flow.account_short || ''; - page.querySelector('[data-ledger-flow-mode]').value = flow.manual_override ? '手动覆盖' : '自动计算'; - flowOpening.value = Number(flow.opening_balance || 0).toFixed(2); - flowInflow.value = Number(flow.inflow || 0).toFixed(2); - flowOutflow.value = Number(flow.outflow || 0).toFixed(2); - page.querySelector('[data-ledger-flow-remark]').value = flow.remark || ''; - updateFlowClosing(); - if (flowEditReset) flowEditReset.hidden = !flow.manual_override; - if (flowEditError) { - flowEditError.hidden = true; - flowEditError.textContent = ''; - } + const result = await loadFlowForDate(button, requestedDate); + if (result.requestId !== flowDateLoadId || activeFlowEditButton !== button) return; + renderFlowEdit(button, result.flow); if (typeof flowEditDialog.showModal === 'function') { flowEditDialog.showModal(); } else { flowEditDialog.setAttribute('open', ''); } - flowOpening.focus(); + if (button.hasAttribute('data-flow-summary-edit')) { + flowDateInput?.focus(); + } else { + flowOpening?.focus(); + } } catch (error) { notify(error.message || '流水读取失败', true); } finally { @@ -3328,6 +3407,39 @@ } }); }); + flowDateInput?.addEventListener('change', async () => { + if (!activeFlowEditButton?.hasAttribute('data-flow-summary-edit')) return; + const requestedDate = flowDateInput.value; + if (!requestedDate) { + if (flowEditError) { + flowEditError.textContent = '请选择流水日期。'; + flowEditError.hidden = false; + } + return; + } + flowDateInput.disabled = true; + if (flowEditSave) flowEditSave.disabled = true; + if (flowEditReset) flowEditReset.hidden = true; + try { + const result = await loadFlowForDate(activeFlowEditButton, requestedDate); + if (result.requestId !== flowDateLoadId || !activeFlowEditButton) return; + renderFlowEdit(activeFlowEditButton, result.flow); + } catch (error) { + if (flowEditError) { + flowEditError.textContent = error.message || '流水读取失败'; + flowEditError.hidden = false; + } + notify(error.message || '流水读取失败', true); + if (flowEditLoadedDate) { + flowDateInput.value = flowEditLoadedDate; + if (flowDateValue) flowDateValue.value = flowEditLoadedDate; + } + } finally { + flowDateInput.disabled = false; + if (flowEditSave) flowEditSave.disabled = false; + flowDateInput.focus(); + } + }); page.querySelector('[data-ledger-flow-close]')?.addEventListener('click', closeFlowEditDialog); page.querySelector('[data-ledger-flow-cancel]')?.addEventListener('click', closeFlowEditDialog); flowEditDialog?.addEventListener('click', (event) => { @@ -3335,6 +3447,7 @@ }); flowEditForm?.addEventListener('submit', async (event) => { event.preventDefault(); + if (flowDateValue && flowDateInput) flowDateValue.value = flowDateInput.value; updateFlowClosing(); if (!flowEditForm.reportValidity()) return; if (flowEditSave) flowEditSave.disabled = true; diff --git a/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php b/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php index a5a9e0c8..19e3e626 100644 --- a/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php +++ b/tests/Feature/Business/FinanceLedgerHeaderControlsTest.php @@ -441,6 +441,9 @@ class FinanceLedgerHeaderControlsTest extends FeatureTestCase $this->assertStringContainsString('账户甲—甲公司', $accountRow[0]); $this->assertStringContainsString('>所属银行', $summaryHtml); $this->assertStringContainsString('100.0010.00110.001', $accountRow[0]); + $this->assertStringContainsString('data-flow-summary-edit', $accountRow[0]); + $this->assertStringContainsString('data-edit-url=', $accountRow[0]); + $this->assertStringContainsString('>编辑', $accountRow[0]); $this->assertSame(1, preg_match('/.*?<\/tr>/s', $summaryHtml, $emptyAccountRow)); $this->assertStringContainsString('账户丙—丙公司', $emptyAccountRow[0]); $this->assertStringContainsString('—', $emptyAccountRow[0]); diff --git a/tests/Feature/Business/FinanceLedgerManualEditTest.php b/tests/Feature/Business/FinanceLedgerManualEditTest.php index 334eedca..69356d44 100644 --- a/tests/Feature/Business/FinanceLedgerManualEditTest.php +++ b/tests/Feature/Business/FinanceLedgerManualEditTest.php @@ -76,6 +76,11 @@ class FinanceLedgerManualEditTest extends FeatureTestCase $this->assertStringContainsString('data-ledger-account-dialog', $html); $this->assertStringContainsString('data-ledger-flow-edit', $html); $this->assertStringContainsString('data-ledger-flow-dialog', $html); + $this->assertSame( + 1, + preg_match('/
/s', $html, $accountsPanel), + ); + $this->assertStringNotContainsString('data-ledger-flow-dialog', $accountsPanel[0]); } public function test_account_editor_updates_fields_renames_references_and_writes_audit(): void @@ -194,6 +199,55 @@ class FinanceLedgerManualEditTest extends FeatureTestCase ], 'business_mirror'); } + public function test_summary_editor_creates_a_flow_for_an_empty_day_and_updates_the_summary(): void + { + Carbon::setTestNow('2026-10-20 12:00:00'); + + $this->loginAs()->get($this->flowEditUrl('2026-10-20')) + ->assertOk() + ->assertJsonPath('flow.id', null) + ->assertJsonPath('flow.flow_date', '2026-10-20') + ->assertJsonPath('flow.opening_balance', 95) + ->assertJsonPath('flow.inflow', 0) + ->assertJsonPath('flow.outflow', 0) + ->assertJsonPath('flow.closing_balance', 95); + + $this->loginAs()->postJson($this->flowUpdateUrl(), [ + 'flow_date' => '2026-10-20', + 'account_short' => '手动编辑测试账户', + 'opening_balance' => 95, + 'inflow' => 10, + 'outflow' => 0, + 'closing_balance' => 105, + 'remark' => '汇总页新建流水', + ])->assertOk() + ->assertJsonPath('flow.manual_override', true) + ->assertJsonPath('flow.closing_balance', 105); + + $this->assertDatabaseHas('fin_daily_flow_overrides', [ + 'flow_date' => '2026-10-20', + 'account_short' => '手动编辑测试账户', + 'inflow' => 10, + 'outflow' => 0, + 'closing_balance' => 105, + ], 'business_mirror'); + + $summaryHtml = $this->loginAs()->get($this->pageUrl([ + 'flows_view' => 'summary', + 'flows_from' => '2026-10-01', + 'flows_to' => '2026-10-31', + ]))->assertOk()->getContent(); + $this->assertSame( + 1, + preg_match('/.*?<\/tr>/s', $summaryHtml, $accountRow), + ); + $this->assertStringContainsString( + '100.0035.0030.00105.00', + $accountRow[0], + ); + $this->assertStringContainsString('data-flow-summary-edit', $accountRow[0]); + } + public function test_manual_edit_validates_equation_duplicate_short_and_permission(): void { $this->withExceptionHandling()->loginAs()->postJson($this->flowUpdateUrl(), [ @@ -239,13 +293,13 @@ class FinanceLedgerManualEditTest extends FeatureTestCase ])->assertForbidden(); } - private function pageUrl(): string + private function pageUrl(array $query = []): string { return route('business.pages.show', [ 'company_id' => $this->company->id, 'section' => 'financial-operations', 'page' => 'ledger', - ]) . '?tab=flows'; + ]) . '?' . http_build_query(array_merge(['tab' => 'flows'], $query)); } private function accountEditUrl(): string