feat(财务台账): 支持手工动账并联动每日流水 #451
@ -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, '只有财务台账管理员可以修改账户状态。');
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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]');
|
||||
|
||||
@ -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');
|
||||
|
||||
236
tests/Feature/Business/FinanceLedgerManualTransactionTest.php
Normal file
236
tests/Feature/Business/FinanceLedgerManualTransactionTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user