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 transactionAccountUrl(int $transactionId): string { return route('business.financial-ledger.transactions.account.update', [ 'company_id' => $this->company->id, 'id' => $transactionId, ]); } 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('data-ledger-manual-date-picker', false) ->assertSee('calendar_today', false) ->assertSee('name="account_short"', false) ->assertSee('list="ledger-manual-account-options"', false) ->assertSee('data-ledger-manual-account', false) ->assertSee('输入账户简称', 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_transaction_row_can_reassign_the_source_account_mapping(): void { $connection = DB::connection('business_mirror'); $transactionId = $connection->table('fin_transactions')->insertGetId([ 'txn_date' => '2026-10-18', 'account_short' => null, 'detail' => '待修正来源账户', 'income' => 12, 'expense' => 0, 'approval_no' => 'MAP-ROW-1', 'source' => 'dingtalk', 'source_value' => 'SOURCE-MAP-ROW-1', 'process_type' => '报销', 'row_key' => 'manual-account-map-row-1', ]); $connection->table('fin_account_map')->insert([ 'source' => 'dingtalk', 'source_value' => 'SOURCE-MAP-ROW-1', 'account_short' => null, 'txn_count' => 1, 'income_sum' => 12, 'expense_sum' => 0, ]); $this->loginAs()->get($this->pageUrl()) ->assertOk() ->assertSee('data-ledger-transaction-account', false) ->assertSee($this->transactionAccountUrl($transactionId), false); $this->loginAs()->postJson($this->transactionAccountUrl($transactionId), [ 'account_short' => '手工账户乙', ]) ->assertOk() ->assertJsonPath('changed', true) ->assertJsonPath('scope', 'mapping') ->assertJsonPath('account_short', '手工账户乙') ->assertJsonPath('daily_flow.closing_balance', 12) ->assertJsonPath('current_balance', 12); $this->assertDatabaseHas('fin_account_map', [ 'source' => 'dingtalk', 'source_value' => 'SOURCE-MAP-ROW-1', 'account_short' => '手工账户乙', 'updated_by' => 'Test User', ], 'business_mirror'); $this->assertDatabaseHas('fin_account_map_events', [ 'source' => 'dingtalk', 'source_value' => 'SOURCE-MAP-ROW-1', 'old_account_short' => null, 'new_account_short' => '手工账户乙', 'user_name' => 'Test User', ], 'business_mirror'); $this->assertDatabaseHas('fin_daily_flows', [ 'flow_date' => '2026-10-18', 'account_short' => '手工账户乙', 'inflow' => 12, 'closing_balance' => 12, ], 'business_mirror'); } public function test_manual_transaction_row_can_change_its_own_account(): void { $this->loginAs()->postJson($this->storeUrl(), $this->payload())->assertCreated(); $transactionId = (int) DB::connection('business_mirror')->table('fin_transactions') ->where('source', FinanceLedgerTransactionWriter::MANUAL_SOURCE) ->value('id'); $this->loginAs()->postJson($this->transactionAccountUrl($transactionId), [ 'account_short' => '手工账户乙', ]) ->assertOk() ->assertJsonPath('changed', true) ->assertJsonPath('scope', 'transaction') ->assertJsonPath('account_short', '手工账户乙') ->assertJsonPath('daily_flow.closing_balance', 25.5) ->assertJsonPath('current_balance', 25.5); $this->assertDatabaseHas('fin_transactions', [ 'id' => $transactionId, 'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE, 'account_short' => '手工账户乙', 'updated_by' => 'Test User', ], 'business_mirror'); $this->assertDatabaseHas('fin_daily_flows', [ 'flow_date' => '2026-10-18', 'account_short' => '手工账户乙', 'inflow' => 25.5, 'closing_balance' => 25.5, ], 'business_mirror'); } 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(); } }