feat(财务台账): 支持手工动账并联动每日流水 #451

Merged
shanshi merged 1 commits from feature/shanshi-manual-transactions into main 2026-10-22 10:54:22 +08:00
9 changed files with 777 additions and 51 deletions

View File

@ -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, '只有财务台账管理员可以修改账户状态。');

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->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');
});
}
}
};

View File

@ -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,
]);
}
}
}

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -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 @@
<span>账户 <b>{{ number_format((int) ($meta['accounts'] ?? 0)) }}</b></span>
<span>店铺 <b>{{ number_format((int) ($meta['shops'] ?? 0)) }}</b></span>
<span>公司 <b>{{ number_format((int) ($meta['companies'] ?? 0)) }}</b></span>
<span>动账 <b>{{ number_format((int) ($meta['transactions'] ?? 0)) }}</b> 笔</span>
<span>动账 <b data-ledger-meta-transactions>{{ number_format((int) ($meta['transactions'] ?? 0)) }}</b> 笔</span>
<span>期初合计 <b>{{ $money($meta['opening_sum'] ?? 0) }}</b></span>
@if (!empty($meta['last_synced_at']))
<span>数据时间 <b>{{ $date($meta['last_synced_at']) }}</b></span>
@ -565,8 +582,8 @@
<div class="ledger-field"><label for="txns-to">日期止</label><input id="txns-to" type="date" name="txns_to" value="{{ request('txns_to') }}"></div>
<div class="ledger-actions"><button class="ledger-btn primary" type="submit"><span class="material-icons-outlined">search</span>查询</button><a class="ledger-btn" href="{{ $tabUrl('transactions') }}"><span class="material-icons-outlined">refresh</span>重置</a></div>
</form>
<div class="ledger-summary"><div class="ledger-summary__item"><span>明细笔数</span><b>{{ number_format((int) ($ledgerTransactionSummary['count'] ?? 0)) }}</b></div><div class="ledger-summary__item"><span>收入合计</span><b>{{ $money($ledgerTransactionSummary['income'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>支出合计</span><b>{{ $money($ledgerTransactionSummary['expense'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>净额</span><b>{{ $money(($ledgerTransactionSummary['income'] ?? 0) - ($ledgerTransactionSummary['expense'] ?? 0)) }}</b></div></div>
<div class="ledger-note">账户简称按“映射表 → 来源原始值 → 未匹配”解析;映射修改后无需重跑明细。</div>
<div class="ledger-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-note">账户简称按“映射表 → 来源原始值 → 未匹配”解析;手工录入使用账户总表简称,并自动计入每日资金流水,不回写上游。</div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:66rem"><thead><tr>
@foreach ($ledgerTableHeaders['transactions'] as $header)
@ -582,7 +599,59 @@
])
@endforeach
</tr></thead>
<tbody>@forelse ($ledgerTransactions as $row)<tr><td>{{ $date($row->txn_date) }}</td><td class="{{ $row->effective_account_short === '未匹配' ? 'danger' : '' }}">{{ $row->effective_account_short }}</td><td class="wrap">{{ $row->detail }}</td><td><span class="ledger-source-tag">{{ $sourceLabels[$row->source] ?? $row->source }}</span></td><td class="num">{{ $row->income > 0 ? $money($row->income) : '' }}</td><td class="num">{{ $row->expense > 0 ? $money($row->expense) : '' }}</td><td>{{ $row->approval_no }}</td></tr>@empty<tr><td colspan="7"><div class="ledger-empty">没有匹配的动账明细。</div></td></tr>@endforelse</tbody>
<tbody data-ledger-transaction-tbody>
@if ($canEdit)
<tr class="ledger-manual-trigger-row" data-ledger-manual-trigger-row>
<td colspan="7">
<button class="ledger-manual-trigger" type="button" data-ledger-manual-trigger @disabled($manualTransactionAccountOptions === [])>
<span class="material-icons-outlined" aria-hidden="true">add</span>
新增手工明细
</button>
</td>
</tr>
<tr class="ledger-manual-draft" data-ledger-manual-draft hidden>
<td>
<form id="ledger-manual-transaction-form" method="POST" action="{{ route('business.financial-ledger.transactions.manual') }}" data-ledger-manual-form>
@csrf
<input type="hidden" name="idempotency_key" value="{{ (string) \Illuminate\Support\Str::uuid() }}" data-ledger-manual-token>
</form>
<input class="ledger-manual-input" form="ledger-manual-transaction-form" type="date" name="txn_date" value="{{ now()->format('Y-m-d') }}" min="{{ $ledgerOpeningDate }}" required>
</td>
<td>
<select class="ledger-manual-input" form="ledger-manual-transaction-form" name="account_short" required>
<option value="">选择账户简称</option>
@foreach ($manualTransactionAccountOptions as $option)
<option value="{{ $option['value'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
</td>
<td><input class="ledger-manual-input" form="ledger-manual-transaction-form" type="text" name="detail" maxlength="4000" placeholder="填写明细" required></td>
<td><span class="ledger-source-tag">手工录入</span></td>
<td><input class="ledger-manual-input" form="ledger-manual-transaction-form" type="number" name="income" min="0" step="0.01" placeholder="0.00" data-ledger-manual-income></td>
<td><input class="ledger-manual-input" form="ledger-manual-transaction-form" type="number" name="expense" min="0" step="0.01" placeholder="0.00" data-ledger-manual-expense></td>
<td>
<div class="ledger-manual-actions">
<input class="ledger-manual-input" form="ledger-manual-transaction-form" type="text" name="approval_no" maxlength="100" placeholder="可留空,自动生成">
<button class="ledger-manual-action is-save" form="ledger-manual-transaction-form" type="submit" title="保存手工明细" aria-label="保存手工明细" data-ledger-manual-save><span class="material-icons-outlined" aria-hidden="true">check</span></button>
<button class="ledger-manual-action" type="button" title="取消" aria-label="取消手工明细" data-ledger-manual-cancel><span class="material-icons-outlined" aria-hidden="true">close</span></button>
</div>
</td>
</tr>
@endif
@forelse ($ledgerTransactions as $row)
<tr class="ledger-transaction-row" data-ledger-transaction-row data-transaction-id="{{ $row->id }}" data-source="{{ $row->source }}">
<td>{{ $date($row->txn_date) }}</td>
<td class="{{ $row->effective_account_short === '未匹配' ? 'danger' : '' }}">{{ $row->effective_account_short }}</td>
<td class="wrap">{{ $row->detail }}</td>
<td><span class="ledger-source-tag">{{ $sourceLabels[$row->source] ?? $row->source }}</span></td>
<td class="num">{{ $row->income > 0 ? $money($row->income) : '' }}</td>
<td class="num">{{ $row->expense > 0 ? $money($row->expense) : '' }}</td>
<td>{{ $row->approval_no }}</td>
</tr>
@empty
<tr><td colspan="7"><div class="ledger-empty">没有匹配的动账明细。</div></td></tr>
@endforelse
</tbody>
</table>
<div class="ledger-pagination"><span>共 {{ number_format($ledgerTransactions->total()) }} 条 · 第 {{ $ledgerTransactions->currentPage() }} / {{ $ledgerTransactions->lastPage() }} 页</span><span>@if (!$ledgerTransactions->onFirstPage())<a href="{{ $ledgerTransactions->previousPageUrl() }}">上一页</a>@endif @if ($ledgerTransactions->hasMorePages())<a href="{{ $ledgerTransactions->nextPageUrl() }}">下一页</a>@endif</span></div>
</div>
@ -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]');

View File

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

View File

@ -0,0 +1,236 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\FinanceLedgerTransactionWriter;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerManualTransactionTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}