diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 6fd7ce12..95802fdb 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -11,11 +11,15 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk; use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk; +use Modules\EcommerceFinance\Services\FinanceLedgerTransactionWriter; +use Modules\EcommerceFinance\View\FinanceLedgerPageData; use Symfony\Component\HttpFoundation\StreamedResponse; class FinanceLedger extends Controller @@ -545,6 +549,130 @@ class FinanceLedger extends Controller ], 200, ['Cache-Control' => 'private, no-store']); } + public function storeManualTransaction( + Request $request, + FinanceLedgerCalculator $calculator, + FinanceLedgerPageData $pageData, + ): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以新增手工动账明细。'); + + $openingDate = (string) config('finance_ledger.opening_date', '2026-10-17'); + $data = $request->validate([ + 'idempotency_key' => ['required', 'uuid'], + 'txn_date' => ['required', 'date', 'after_or_equal:' . $openingDate], + 'account_short' => ['required', 'string', 'max:100'], + 'detail' => ['required', 'string', 'max:4000'], + 'income' => ['nullable', 'numeric', 'min:0', 'max:9999999999999999.99'], + 'expense' => ['nullable', 'numeric', 'min:0', 'max:9999999999999999.99'], + 'approval_no' => ['nullable', 'string', 'max:100'], + ]); + + $accountShort = trim((string) $data['account_short']); + $income = round((float) ($data['income'] ?? 0), 2); + $expense = round((float) ($data['expense'] ?? 0), 2); + if (($income > 0) === ($expense > 0)) { + throw ValidationException::withMessages([ + 'income' => '收入和支出只能填写一项。', + ]); + } + + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $account = $connection->table('fin_accounts') + ->where('account_short', $accountShort) + ->orderBy('id') + ->first(['account_short', 'company_name']); + abort_if($account === null, 422, '账户简称不存在,请从账户总表中选择。'); + + $idempotencyKey = (string) $data['idempotency_key']; + $rowKey = hash('sha256', FinanceLedgerTransactionWriter::MANUAL_SOURCE . ':' . $idempotencyKey); + $detail = preg_replace('/\s+/u', ' ', trim((string) $data['detail'])) ?? trim((string) $data['detail']); + $approvalNo = trim((string) ($data['approval_no'] ?? '')); + if ($approvalNo === '') { + $approvalNo = 'MAN-' . str_replace('-', '', (string) $data['txn_date']) . '-' . strtoupper(substr($rowKey, 0, 6)); + } + + $inserted = $connection->transaction(function () use ( + $connection, + $calculator, + $rowKey, + $data, + $accountShort, + $detail, + $income, + $expense, + $approvalNo, + ): bool { + $now = now(); + $created = $connection->table('fin_transactions')->insertOrIgnore([ + 'txn_date' => $data['txn_date'], + 'account_short' => $accountShort, + 'detail' => $detail, + 'income' => $income, + 'expense' => $expense, + 'approval_no' => mb_substr($approvalNo, 0, 100), + 'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE, + 'source_value' => null, + 'process_type' => '手工录入', + 'row_key' => $rowKey, + 'synced_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + 'created_by' => (string) user()->name, + 'updated_by' => (string) user()->name, + ]) === 1; + + if ($created) { + $calculator->recalculate(); + } + + return $created; + }); + + $row = $connection->table('fin_transactions')->where('row_key', $rowKey)->first(); + abort_if($row === null, 500, '手工动账明细保存失败,请刷新后重试。'); + + $accountInfo = $connection->table('fin_accounts') + ->where('account_short', $accountShort) + ->orderBy('id') + ->first(['account_short', 'company_name', 'current_balance']); + $flow = $connection->table('fin_daily_flows') + ->where('account_short', $accountShort) + ->whereDate('flow_date', (string) $row->txn_date) + ->first(['flow_date', 'opening_balance', 'inflow', 'outflow', 'closing_balance']); + return response()->json([ + 'ok' => true, + 'created' => $inserted, + 'row' => [ + 'id' => (int) $row->id, + 'txn_date' => (string) $row->txn_date, + 'account_short' => (string) $row->account_short, + 'company_name' => (string) ($accountInfo->company_name ?? ''), + 'detail' => (string) $row->detail, + 'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE, + 'source_label' => '手工录入', + 'income' => round((float) $row->income, 2), + 'expense' => round((float) $row->expense, 2), + 'approval_no' => (string) ($row->approval_no ?? ''), + ], + 'account' => [ + 'account_short' => (string) ($accountInfo->account_short ?? $accountShort), + 'company_name' => (string) ($accountInfo->company_name ?? ''), + 'current_balance' => round((float) ($accountInfo->current_balance ?? 0), 2), + ], + 'daily_flow' => $flow === null ? null : [ + 'flow_date' => (string) $flow->flow_date, + 'opening_balance' => round((float) $flow->opening_balance, 2), + 'inflow' => round((float) $flow->inflow, 2), + 'outflow' => round((float) $flow->outflow, 2), + 'closing_balance' => round((float) $flow->closing_balance, 2), + ], + 'summary' => $pageData->transactionSummary(), + 'total_transactions' => $connection->table('fin_transactions')->count(), + 'next_idempotency_key' => (string) Str::uuid(), + ], $inserted ? 201 : 200, ['Cache-Control' => 'private, no-store']); + } + public function updateAccountStatus(Request $request, FinanceLedgerCalculator $calculator): JsonResponse { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户状态。'); diff --git a/database/migrations/2026_10_22_090000_add_manual_metadata_to_finance_ledger_transactions.php b/database/migrations/2026_10_22_090000_add_manual_metadata_to_finance_ledger_transactions.php new file mode 100644 index 00000000..739942ca --- /dev/null +++ b/database/migrations/2026_10_22_090000_add_manual_metadata_to_finance_ledger_transactions.php @@ -0,0 +1,53 @@ +connection()); + if (! $schema->hasTable('fin_transactions')) { + return; + } + + if (! $schema->hasColumn('fin_transactions', 'created_by')) { + $schema->table('fin_transactions', function (Blueprint $table): void { + $table->string('created_by', 100)->nullable()->after('updated_at'); + }); + } + + if (! $schema->hasColumn('fin_transactions', 'updated_by')) { + $schema->table('fin_transactions', function (Blueprint $table): void { + $table->string('updated_by', 100)->nullable()->after('created_by'); + }); + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('fin_transactions')) { + return; + } + + if ($schema->hasColumn('fin_transactions', 'updated_by')) { + $schema->table('fin_transactions', function (Blueprint $table): void { + $table->dropColumn('updated_by'); + }); + } + + if ($schema->hasColumn('fin_transactions', 'created_by')) { + $schema->table('fin_transactions', function (Blueprint $table): void { + $table->dropColumn('created_by'); + }); + } + } +}; diff --git a/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php b/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php index 47ac5b54..a3838009 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php @@ -32,6 +32,9 @@ class FinanceLedgerCalculator $accountCompanies = $connection->table('fin_accounts') ->select(['account_short', 'company_name']) + ->whereNotNull('account_short') + ->where('account_short', '<>', '') + ->orderBy('id') ->get() ->groupBy('account_short') ->map(static fn ($rows) => (string) ($rows->first()->company_name ?? '')); @@ -48,7 +51,7 @@ class FinanceLedgerCalculator ->orderBy('id') ->get(['txn_date', 'account_short', 'source', 'source_value', 'income', 'expense']); - $daily = []; + $dailyByAccount = []; $balances = []; foreach ($transactions as $transaction) { @@ -57,35 +60,32 @@ class FinanceLedgerCalculator if ($account === '') { $account = trim((string) $transaction->account_short); } - if ($account === '' || ! $opening->has($account)) { + if ($account === '' || ! $accountCompanies->has($account)) { continue; } $date = (string) $transaction->txn_date; - $key = $account . "\0" . $date; - $daily[$key] ??= [ - 'account_short' => $account, + $dailyByAccount[$account][$date] ??= [ 'flow_date' => $date, 'inflow' => 0.0, 'outflow' => 0.0, ]; - $daily[$key]['inflow'] += (float) $transaction->income; - $daily[$key]['outflow'] += (float) $transaction->expense; + $dailyByAccount[$account][$date]['inflow'] += (float) $transaction->income; + $dailyByAccount[$account][$date]['outflow'] += (float) $transaction->expense; } $flows = []; $latest = []; - foreach ($opening as $account => $openingRow) { + foreach ($accountCompanies as $account => $company) { $account = (string) $account; - $balance = (float) $openingRow->opening_balance; - $company = $accountCompanies->get($account, ''); - $start = max((string) $openingRow->opening_date, $openingDate); + $openingRow = $opening->get($account); + $balance = $openingRow === null ? 0.0 : (float) $openingRow->opening_balance; + $start = $openingRow === null + ? $openingDate + : max((string) $openingRow->opening_date, $openingDate); - $rows = array_values(array_filter( - $daily, - static fn (array $row): bool => $row['account_short'] === $account, - )); + $rows = array_values($dailyByAccount[$account] ?? []); usort($rows, static fn (array $a, array $b): int => strcmp($a['flow_date'], $b['flow_date'])); $openingFlow = [ @@ -147,27 +147,31 @@ class FinanceLedgerCalculator $connection->table('fin_daily_flows')->insert($chunk); } - foreach ($latest as $account => $balance) { - $connection->table('fin_accounts') - ->where('account_short', $account) - ->update([ - 'current_balance' => round((float) $balance, 2), - 'updated_at' => $now, - ]); - } + $this->updateBalances( + $connection, + 'fin_accounts', + 'account_short', + 'current_balance', + $latest, + $now, + ); $this->refreshAccountAmounts(); $companies = $connection->table('fin_accounts') ->selectRaw('company_name, SUM(current_balance) AS balance') ->groupBy('company_name') - ->pluck('balance', 'company_name'); + ->pluck('balance', 'company_name') + ->all(); - foreach ($companies as $company => $balance) { - $connection->table('fin_shops') - ->where('company_name', (string) $company) - ->update(['shop_balance' => round((float) $balance, 2), 'updated_at' => $now]); - } + $this->updateBalances( + $connection, + 'fin_shops', + 'company_name', + 'shop_balance', + $companies, + $now, + ); }); return [ @@ -234,4 +238,37 @@ class FinanceLedgerCalculator { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); } + + private function updateBalances( + $connection, + string $table, + string $keyColumn, + string $balanceColumn, + array $balances, + $now, + ): void { + foreach (array_chunk($balances, 500, true) as $chunk) { + if ($chunk === []) { + continue; + } + + $caseClauses = []; + foreach ($chunk as $key => $balance) { + $caseClauses[] = 'WHEN ' + . $connection->getPdo()->quote((string) $key) + . ' THEN ' + . number_format(round((float) $balance, 2), 2, '.', ''); + } + + $connection->table($table) + ->whereIn($keyColumn, array_keys($chunk)) + ->update([ + $balanceColumn => $connection->raw( + "CASE `{$keyColumn}` " . implode(' ', $caseClauses) + . " ELSE `{$balanceColumn}` END" + ), + 'updated_at' => $now, + ]); + } + } } diff --git a/modules/EcommerceFinance/Services/FinanceLedgerSnapshotImporter.php b/modules/EcommerceFinance/Services/FinanceLedgerSnapshotImporter.php index 277229a3..9493935b 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerSnapshotImporter.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerSnapshotImporter.php @@ -100,7 +100,9 @@ class FinanceLedgerSnapshotImporter } $connection->transaction(function () use ($connection, $rows, $now): void { - $connection->table('fin_transactions')->delete(); + $connection->table('fin_transactions') + ->where('source', '<>', FinanceLedgerTransactionWriter::MANUAL_SOURCE) + ->delete(); foreach (array_chunk($rows, 500) as $chunk) { $connection->table('fin_transactions')->insert($chunk); } @@ -126,6 +128,7 @@ class FinanceLedgerSnapshotImporter { $stats = $connection->table('fin_transactions') ->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum') + ->where('source', '<>', FinanceLedgerTransactionWriter::MANUAL_SOURCE) ->whereNotNull('source_value') ->where('source_value', '<>', '') ->groupBy('source', 'source_value') diff --git a/modules/EcommerceFinance/Services/FinanceLedgerTransactionWriter.php b/modules/EcommerceFinance/Services/FinanceLedgerTransactionWriter.php index 27dfc2fa..c0d83e63 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerTransactionWriter.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerTransactionWriter.php @@ -13,6 +13,8 @@ use RuntimeException; */ class FinanceLedgerTransactionWriter { + public const MANUAL_SOURCE = 'manual'; + public function replace(array $transactions, bool $recalculate = true): array { return $this->persist($transactions, $recalculate, true); @@ -87,7 +89,9 @@ class FinanceLedgerTransactionWriter $connection->transaction(function () use ($connection, $rows, $now, $replaceExisting): void { if ($replaceExisting) { - $connection->table('fin_transactions')->delete(); + $connection->table('fin_transactions') + ->where('source', '<>', self::MANUAL_SOURCE) + ->delete(); } foreach (array_chunk($rows, 500) as $chunk) { if ($replaceExisting) { @@ -123,6 +127,7 @@ class FinanceLedgerTransactionWriter { $stats = $connection->table('fin_transactions') ->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum') + ->where('source', '<>', self::MANUAL_SOURCE) ->whereNotNull('source_value') ->where('source_value', '<>', '') ->groupBy('source', 'source_value') diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index 19cb3fa7..9906b8e3 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -57,6 +57,7 @@ class FinanceLedgerPageData 'ledgerSourceSummary' => $this->sourceSummary(), 'ledgerMeta' => $this->meta(), 'ledgerAccountOptions' => $this->accountOptions(), + 'ledgerManualTransactionAccountOptions' => $this->manualTransactionAccountOptions(), 'ledgerAccountFilterOptions' => $this->accountFilterOptions(), 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, 'ledgerAccountPlanOptions' => $this->accountPlanOptions(), @@ -274,7 +275,32 @@ class FinanceLedgerPageData private function transactions(): array { $query = $this->transactionQuery(); + $this->applyTransactionFilters($query); + $headerColumns = $this->transactionHeaderColumns(); + $summary = $this->transactionSummaryMetrics($query); + $sorted = $this->applyLedgerHeaderSort($query, 'transactions', $headerColumns); + if (! $sorted) { + $query->orderByDesc('t.txn_date')->orderByDesc('t.id'); + } + + return [ + 'rows' => $query + ->paginate(100, ['*'], 'txns_page')->withQueryString(), + 'summary' => $summary, + ]; + } + + public function transactionSummary(): array + { + $query = $this->transactionQuery(); + $this->applyTransactionFilters($query); + + return $this->transactionSummaryMetrics($query); + } + + private function applyTransactionFilters(Builder $query): void + { $keyword = trim((string) request('txns_q', '')); if ($keyword !== '') { $query->where(function (Builder $builder) use ($keyword): void { @@ -302,24 +328,16 @@ class FinanceLedgerPageData $query->where('t.source', $source); } - $headerColumns = $this->transactionHeaderColumns(); - $this->applyLedgerHeaderFilter($query, 'transactions', $headerColumns); - $summary = [ + $this->applyLedgerHeaderFilter($query, 'transactions', $this->transactionHeaderColumns()); + } + + private function transactionSummaryMetrics(Builder $query): array + { + return [ '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 - ->paginate(100, ['*'], 'txns_page')->withQueryString(), - 'summary' => $summary, - ]; } private function transactionQuery(): Builder @@ -432,6 +450,33 @@ class FinanceLedgerPageData ->select('account_short')->distinct()->orderBy('account_short')->pluck('account_short')->all(); } + private function manualTransactionAccountOptions(): array + { + return $this->db()->table('fin_accounts') + ->select(['account_short', 'company_name']) + ->whereNotNull('account_short') + ->where('account_short', '<>', '') + ->orderBy('account_short') + ->orderBy('company_name') + ->orderBy('id') + ->get() + ->groupBy('account_short') + ->map(static function ($rows, string $accountShort): array { + $first = $rows->first(); + $companyName = trim((string) ($first->company_name ?? '')); + $accountCount = $rows->count(); + + return [ + 'value' => $accountShort, + 'label' => $accountShort + . ($companyName !== '' ? ' · ' . $companyName : '') + . ($accountCount > 1 ? '(' . $accountCount . ' 个账号)' : ''), + ]; + }) + ->values() + ->all(); + } + private function accountFilterOptions(): array { $baseTypes = $this->db()->table('fin_accounts') diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 630fe995..408b6747 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -54,7 +54,8 @@ $date = static fn ($value): string => $value ? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d') : ''; $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' => '聚工单']; + $sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入']; + $manualTransactionAccountOptions = $ledgerManualTransactionAccountOptions ?? []; $optionMap = static fn (array $values): array => array_combine($values, $values) ?: []; $ledgerHeaderPreserve = []; foreach (array_keys($tabs) as $tabKey) { @@ -261,6 +262,22 @@ .ledger-table th:first-child { z-index:3; background:var(--vip-content-accent-soft, #F7F0DE); } .ledger-table tr:hover td:first-child { background:#fcfaf4; } .ledger-table td.num, .ledger-table th.num { text-align:right; font-variant-numeric:tabular-nums; } + .ledger-manual-trigger-row td { position:static !important; padding:.18rem .45rem; background:#fbf8f0; } + .ledger-manual-trigger { display:inline-flex; align-items:center; gap:.35rem; height:1.75rem; padding:0 .55rem; border:0; border-radius:.35rem; background:transparent; color:var(--vip-content-accent-dark, #856632); font:inherit; font-size:.7rem; font-weight:700; cursor:pointer; } + .ledger-manual-trigger:hover { background:var(--vip-content-accent-soft, #F7F0DE); } + .ledger-manual-trigger .material-icons-outlined { font-size:1rem; } + .ledger-manual-draft[hidden] { display:none !important; } + .ledger-manual-draft td { position:static !important; padding:.35rem .4rem; background:#fffdf7; vertical-align:middle; } + .ledger-manual-input { width:100%; min-width:0; height:1.85rem; padding:0 .42rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.35rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-size:.7rem; } + .ledger-manual-input:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.14); } + .ledger-manual-input:disabled { background:#f5f2eb; color:var(--vip-content-muted, #595959); } + .ledger-manual-actions { display:flex; align-items:center; gap:.25rem; } + .ledger-manual-action { display:inline-grid; place-items:center; width:1.75rem; height:1.75rem; padding:0; border:1px solid var(--vip-border, #D8D2C7); border-radius:.35rem; background:#fff; color:var(--vip-content-muted, #595959); cursor:pointer; } + .ledger-manual-action.is-save { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-dark, #856632); color:#fff; } + .ledger-manual-action:disabled { opacity:.55; cursor:wait; } + .ledger-manual-action .material-icons-outlined { font-size:1rem; } + .ledger-transaction-row.is-new td { animation:ledgerManualRowIn .28s ease-out; } + @keyframes ledgerManualRowIn { from { background:#fff4d7; } to { background:transparent; } } .ledger-accounts-table thead th, .ledger-accounts-table thead th.num { text-align:center; } .ledger-accounts-table tbody td.center { text-align:center; } @@ -340,7 +357,7 @@ 账户 {{ number_format((int) ($meta['accounts'] ?? 0)) }} 店铺 {{ number_format((int) ($meta['shops'] ?? 0)) }} 公司 {{ number_format((int) ($meta['companies'] ?? 0)) }} - 动账 {{ number_format((int) ($meta['transactions'] ?? 0)) }} 笔 + 动账 {{ number_format((int) ($meta['transactions'] ?? 0)) }} 笔 期初合计 {{ $money($meta['opening_sum'] ?? 0) }} @if (!empty($meta['last_synced_at'])) 数据时间 {{ $date($meta['last_synced_at']) }} @@ -565,8 +582,8 @@
refresh重置
-
明细笔数{{ number_format((int) ($ledgerTransactionSummary['count'] ?? 0)) }}
收入合计{{ $money($ledgerTransactionSummary['income'] ?? 0) }}
支出合计{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}
净额{{ $money(($ledgerTransactionSummary['income'] ?? 0) - ($ledgerTransactionSummary['expense'] ?? 0)) }}
-
账户简称按“映射表 → 来源原始值 → 未匹配”解析;映射修改后无需重跑明细。
+
明细笔数{{ number_format((int) ($ledgerTransactionSummary['count'] ?? 0)) }}
收入合计{{ $money($ledgerTransactionSummary['income'] ?? 0) }}
支出合计{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}
净额{{ $money(($ledgerTransactionSummary['income'] ?? 0) - ($ledgerTransactionSummary['expense'] ?? 0)) }}
+
账户简称按“映射表 → 来源原始值 → 未匹配”解析;手工录入使用账户总表简称,并自动计入每日资金流水,不回写上游。
@foreach ($ledgerTableHeaders['transactions'] as $header) @@ -582,7 +599,59 @@ ]) @endforeach - @forelse ($ledgerTransactions as $row)@empty@endforelse + + @if ($canEdit) + + + + + + + + + + + + + @endif + @forelse ($ledgerTransactions as $row) + + + + + + + + + + @empty + + @endforelse +
{{ $date($row->txn_date) }}{{ $row->detail }}{{ $sourceLabels[$row->source] ?? $row->source }}{{ $row->income > 0 ? $money($row->income) : '' }}{{ $row->expense > 0 ? $money($row->expense) : '' }}{{ $row->approval_no }}
没有匹配的动账明细。
+ +
{{ $date($row->txn_date) }}{{ $row->detail }}{{ $sourceLabels[$row->source] ?? $row->source }}{{ $row->income > 0 ? $money($row->income) : '' }}{{ $row->expense > 0 ? $money($row->expense) : '' }}{{ $row->approval_no }}
没有匹配的动账明细。
共 {{ number_format($ledgerTransactions->total()) }} 条 · 第 {{ $ledgerTransactions->currentPage() }} / {{ $ledgerTransactions->lastPage() }} 页@if (!$ledgerTransactions->onFirstPage())上一页@endif @if ($ledgerTransactions->hasMorePages())下一页@endif
@@ -679,6 +748,7 @@ const tabs = Array.from(page.querySelectorAll('[data-ledger-tab]')); const panels = Array.from(page.querySelectorAll('[data-ledger-panel]')); + let ledgerDependentDataStale = false; const showTab = (name, updateUrl = true) => { tabs.forEach((tab) => { const active = tab.dataset.ledgerTab === name; @@ -692,7 +762,16 @@ window.history.replaceState({}, '', url.toString()); } }; - tabs.forEach((tab) => tab.addEventListener('click', () => showTab(tab.dataset.ledgerTab))); + tabs.forEach((tab) => tab.addEventListener('click', () => { + const name = tab.dataset.ledgerTab; + if (ledgerDependentDataStale && ['accounts', 'shops', 'flows', 'sources'].includes(name)) { + const url = new URL(window.location.href); + url.searchParams.set('tab', name); + window.location.assign(url.toString()); + return; + } + showTab(name); + })); if (window.location.hash === '#mapping') showTab('mapping', false); const headerDetails = Array.from(page.querySelectorAll('[data-ledger-header-details]')); @@ -727,6 +806,142 @@ } }; + const manualTransactionTrigger = page.querySelector('[data-ledger-manual-trigger]'); + const manualTransactionDraft = page.querySelector('[data-ledger-manual-draft]'); + const manualTransactionForm = page.querySelector('[data-ledger-manual-form]'); + if (manualTransactionTrigger && manualTransactionDraft && manualTransactionForm) { + const triggerRow = page.querySelector('[data-ledger-manual-trigger-row]'); + const tokenInput = manualTransactionForm.querySelector('[data-ledger-manual-token]'); + const saveButton = page.querySelector('[data-ledger-manual-save]'); + const cancelButton = page.querySelector('[data-ledger-manual-cancel]'); + 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 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 metaTransactions = page.querySelector('[data-ledger-meta-transactions]'); + const moneyFormatter = new Intl.NumberFormat('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + const integerFormatter = new Intl.NumberFormat('zh-CN'); + + const syncManualDirection = (activeInput = null) => { + if (!incomeInput || !expenseInput) return; + if (activeInput === incomeInput && Number(incomeInput.value) > 0) { + expenseInput.value = ''; + } else if (activeInput === expenseInput && Number(expenseInput.value) > 0) { + incomeInput.value = ''; + } + const incomeActive = Number(incomeInput.value) > 0; + const expenseActive = Number(expenseInput.value) > 0; + incomeInput.disabled = expenseActive; + expenseInput.disabled = incomeActive; + }; + + const showManualDraft = (visible) => { + if (triggerRow) triggerRow.hidden = visible; + manualTransactionDraft.hidden = !visible; + if (visible) { + manualTransactionDraft.querySelector('input[name="txn_date"]')?.focus(); + } + }; + + const resetManualDraft = (nextToken = null) => { + manualTransactionForm.reset(); + if (nextToken) tokenInput.dataset.currentToken = nextToken; + if (tokenInput?.dataset.currentToken) tokenInput.value = tokenInput.dataset.currentToken; + syncManualDirection(); + showManualDraft(false); + }; + + const appendManualRow = (row) => { + const tableRow = document.createElement('tr'); + tableRow.className = 'ledger-transaction-row is-new'; + tableRow.dataset.ledgerTransactionRow = ''; + tableRow.dataset.transactionId = String(row.id); + tableRow.dataset.source = row.source; + + const addCell = (value = '') => { + const cell = document.createElement('td'); + cell.textContent = value; + tableRow.appendChild(cell); + return cell; + }; + + addCell(row.txn_date); + addCell(row.account_short); + addCell(row.detail).classList.add('wrap'); + const sourceCell = addCell(); + const sourceTag = document.createElement('span'); + sourceTag.className = 'ledger-source-tag'; + sourceTag.textContent = row.source_label; + sourceCell.appendChild(sourceTag); + addCell(row.income > 0 ? moneyFormatter.format(row.income) : '').classList.add('num'); + addCell(row.expense > 0 ? moneyFormatter.format(row.expense) : '').classList.add('num'); + addCell(row.approval_no); + + manualTransactionDraft.insertAdjacentElement('afterend', tableRow); + }; + + manualTransactionTrigger.addEventListener('click', () => showManualDraft(true)); + cancelButton?.addEventListener('click', () => resetManualDraft()); + incomeInput?.addEventListener('input', () => syncManualDirection(incomeInput)); + expenseInput?.addEventListener('input', () => syncManualDirection(expenseInput)); + + manualTransactionForm.addEventListener('submit', async (event) => { + event.preventDefault(); + if (!manualTransactionForm.reportValidity()) return; + const income = Number(incomeInput?.value || 0); + const expense = Number(expenseInput?.value || 0); + if ((income > 0) === (expense > 0)) { + notify('收入和支出只能填写一项。', true); + return; + } + + saveButton && (saveButton.disabled = true); + manualTransactionForm.setAttribute('aria-busy', 'true'); + try { + const requestUrl = new URL(manualTransactionForm.action, window.location.origin); + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.forEach((value, key) => requestUrl.searchParams.set(key, value)); + const response = await fetch(requestUrl.toString(), { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: new FormData(manualTransactionForm), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const firstError = Object.values(payload.errors || {})[0]?.[0]; + throw new Error(firstError || payload.message || '手工明细保存失败'); + } + + appendManualRow(payload.row); + if (summaryCount) summaryCount.textContent = integerFormatter.format(payload.summary.count); + 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 (metaTransactions) metaTransactions.textContent = integerFormatter.format(payload.total_transactions); + ledgerDependentDataStale = true; + resetManualDraft(payload.next_idempotency_key); + const balance = payload.daily_flow + ? ',当日期末余额 ' + moneyFormatter.format(payload.daily_flow.closing_balance) + : ''; + notify('手工明细已保存并计入每日资金流水' + balance); + } catch (error) { + notify(error.message || '手工明细保存失败', true); + } finally { + saveButton && (saveButton.disabled = false); + manualTransactionForm.removeAttribute('aria-busy'); + } + }); + } + const accountTemplateDownload = page.querySelector('[data-account-template-download]'); accountTemplateDownload?.addEventListener('click', () => { const status = page.querySelector('[data-account-template-download-status]'); diff --git a/routes/admin.php b/routes/admin.php index 3662449a..902c599d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -665,6 +665,10 @@ Route::post('workspace/financial-ledger/mapping/{id}', [\App\Http\Controllers\Bu ->middleware('business.page:financial-operations,ledger') ->name('business.financial-ledger.mapping.update'); +Route::post('workspace/financial-ledger/transactions/manual', [\App\Http\Controllers\Business\FinanceLedger::class, 'storeManualTransaction']) + ->middleware('business.page:financial-operations,ledger') + ->name('business.financial-ledger.transactions.manual'); + Route::post('workspace/financial-ledger/accounts/status', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountStatus']) ->middleware('business.page:financial-operations,ledger') ->name('business.financial-ledger.accounts.status'); diff --git a/tests/Feature/Business/FinanceLedgerManualTransactionTest.php b/tests/Feature/Business/FinanceLedgerManualTransactionTest.php new file mode 100644 index 00000000..9574e815 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerManualTransactionTest.php @@ -0,0 +1,236 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + DB::connection('business_mirror')->table('fin_accounts')->insert([ + [ + 'company_name' => '手工测试公司甲', + 'account_short' => '手工账户甲', + 'account_type' => '公户', + 'current_balance' => 100, + ], + [ + 'company_name' => '手工测试公司乙', + 'account_short' => '手工账户乙', + 'account_type' => '个人卡', + 'current_balance' => 0, + ], + ]); + DB::connection('business_mirror')->table('fin_account_opening')->insert([ + 'account_short' => '手工账户甲', + 'opening_date' => '2026-10-17', + 'opening_balance' => 100, + ]); + } + + private function pageUrl(): string + { + return route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-operations', + 'page' => 'ledger', + ]) . '?tab=transactions'; + } + + private function storeUrl(): string + { + return route('business.financial-ledger.transactions.manual', [ + 'company_id' => $this->company->id, + ]); + } + + private function payload(array $overrides = []): array + { + return array_merge([ + 'idempotency_key' => '72901f47-0557-45f8-8519-8a51cc0efb52', + 'txn_date' => '2026-10-18', + 'account_short' => '手工账户甲', + 'detail' => '手工补录测试', + 'income' => 25.50, + 'expense' => 0, + 'approval_no' => '', + ], $overrides); + } + + public function test_transaction_page_exposes_account_master_picker_and_inline_add_row(): void + { + $this->loginAs()->get($this->pageUrl()) + ->assertOk() + ->assertSee('新增手工明细') + ->assertSee('data-ledger-manual-trigger', false) + ->assertSee('data-ledger-manual-draft', false) + ->assertSee('name="account_short"', false) + ->assertSee('手工账户甲 · 手工测试公司甲', false) + ->assertSee('name="income"', false) + ->assertSee('name="expense"', false) + ->assertSee('name="approval_no"', false) + ->assertSee('手工录入'); + } + + public function test_manual_income_is_saved_and_updates_daily_flow_and_current_balance(): void + { + $response = $this->loginAs()->postJson($this->storeUrl(), $this->payload()) + ->assertCreated() + ->assertJsonPath('row.source', FinanceLedgerTransactionWriter::MANUAL_SOURCE) + ->assertJsonPath('row.account_short', '手工账户甲') + ->assertJsonPath('daily_flow.opening_balance', 100) + ->assertJsonPath('daily_flow.inflow', 25.5) + ->assertJsonPath('daily_flow.closing_balance', 125.5) + ->assertJsonPath('account.current_balance', 125.5) + ->assertJsonPath('summary.count', 1) + ->assertJsonPath('summary.income', 25.5) + ->assertJsonPath('summary.expense', 0) + ->assertJsonPath('total_transactions', 1); + + $this->assertNotEmpty($response->json('row.approval_no')); + $this->assertDatabaseHas('fin_transactions', [ + 'account_short' => '手工账户甲', + 'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE, + 'created_by' => 'Test User', + 'updated_by' => 'Test User', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_daily_flows', [ + 'flow_date' => '2026-10-18', + 'account_short' => '手工账户甲', + 'company_name' => '手工测试公司甲', + 'opening_balance' => 100, + 'inflow' => 25.5, + 'outflow' => 0, + 'closing_balance' => 125.5, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_accounts', [ + 'account_short' => '手工账户甲', + 'current_balance' => 125.5, + ], 'business_mirror'); + } + + public function test_account_without_opening_gets_zero_flow_and_can_receive_manual_expense(): void + { + $this->loginAs()->postJson($this->storeUrl(), $this->payload([ + 'idempotency_key' => '0d1066f0-2b23-49b3-a6a0-91bf1f93d1c1', + 'account_short' => '手工账户乙', + 'detail' => '无期初账户手工支出', + 'income' => 0, + 'expense' => 20, + ]))->assertCreated()->assertJsonPath('daily_flow.closing_balance', -20); + + $connection = DB::connection('business_mirror'); + $this->assertDatabaseHas('fin_daily_flows', [ + 'flow_date' => '2026-10-17', + 'account_short' => '手工账户乙', + 'company_name' => '手工测试公司乙', + 'opening_balance' => 0, + 'closing_balance' => 0, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_daily_flows', [ + 'flow_date' => '2026-10-18', + 'account_short' => '手工账户乙', + 'opening_balance' => 0, + 'outflow' => 20, + 'closing_balance' => -20, + ], 'business_mirror'); + $this->assertSame(-20.0, (float) $connection->table('fin_accounts') + ->where('account_short', '手工账户乙') + ->value('current_balance')); + } + + public function test_manual_submit_is_idempotent_and_survives_upstream_replace(): void + { + $this->loginAs()->postJson($this->storeUrl(), $this->payload())->assertCreated(); + $this->loginAs()->postJson($this->storeUrl(), $this->payload()) + ->assertOk() + ->assertJsonPath('created', false); + + $connection = DB::connection('business_mirror'); + $this->assertSame(1, $connection->table('fin_transactions') + ->where('source', FinanceLedgerTransactionWriter::MANUAL_SOURCE) + ->count()); + + app(FinanceLedgerTransactionWriter::class)->replace([ + [ + 'txn_date' => '2026-10-19', + 'account_short' => '同步账户', + 'detail' => '上游同步明细', + 'income' => 10, + 'expense' => 0, + 'approval_no' => 'UPSTREAM-1', + 'source' => 'dingtalk', + 'source_value' => 'UPSTREAM-VALUE-1', + 'process_type' => '报销', + ], + ], false); + + $this->assertDatabaseHas('fin_transactions', [ + 'account_short' => '手工账户甲', + 'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_transactions', [ + 'detail' => '上游同步明细', + 'source' => 'dingtalk', + ], 'business_mirror'); + } + + public function test_manual_submit_returns_summary_for_current_transaction_filters(): void + { + $this->loginAs()->postJson( + $this->storeUrl() . '?txns_account=' . urlencode('不匹配账户'), + $this->payload() + ) + ->assertCreated() + ->assertJsonPath('summary.count', 0) + ->assertJsonPath('summary.income', 0) + ->assertJsonPath('summary.expense', 0) + ->assertJsonPath('total_transactions', 1); + } + + public function test_manual_submit_validates_amount_account_date_and_permission(): void + { + $this->withExceptionHandling()->loginAs()->postJson($this->storeUrl(), $this->payload([ + 'income' => 10, + 'expense' => 5, + ]))->assertUnprocessable()->assertJsonValidationErrors('income'); + + $this->withExceptionHandling()->loginAs()->postJson($this->storeUrl(), $this->payload([ + 'idempotency_key' => '3f25a128-f733-4b99-883c-9d5d6ec2639f', + 'account_short' => '不存在的账户', + ]))->assertStatus(422); + + $this->withExceptionHandling()->loginAs()->postJson($this->storeUrl(), $this->payload([ + 'idempotency_key' => '1b035dae-f7b5-4b01-aac7-12cb4f27ab37', + 'txn_date' => '2026-10-16', + ]))->assertUnprocessable()->assertJsonValidationErrors('txn_date'); + + $viewer = user_model_class()::create([ + 'name' => '手工明细只读用户', + 'email' => 'manual-viewer-' . uniqid() . '@example.com', + 'password' => 'local-test-password', + 'locale' => 'zh-CN', + 'enabled' => 1, + 'landing_page' => 'dashboard', + 'created_from' => 'core::test', + ]); + $viewer->companies()->attach($this->company->id); + $viewer->roles()->attach(Role::where('name', 'user')->firstOrFail()->id); + $viewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->withExceptionHandling()->loginAs($viewer)->postJson($this->storeUrl(), $this->payload([ + 'idempotency_key' => '9b31ee32-9a72-4d8f-a144-e2ca9ca5ecdc', + ]))->assertForbidden(); + } +}