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, ]); } protected function tearDown(): void { Carbon::setTestNow(); parent::tearDown(); } 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 manualUpdateUrl(int $transactionId): string { return route('business.financial-ledger.transactions.manual.update', [ 'company_id' => $this->company->id, 'id' => $transactionId, ]); } private function manualDeleteUrl(int $transactionId): string { return route('business.financial-ledger.transactions.manual.delete', [ 'company_id' => $this->company->id, 'id' => $transactionId, ]); } private function independentUpdateUrl(int $transactionId): string { return route('business.financial-ledger.transactions.statement.update', [ 'company_id' => $this->company->id, 'id' => $transactionId, ]); } private function independentDeleteUrl(int $transactionId): string { return route('business.financial-ledger.transactions.statement.delete', [ 'company_id' => $this->company->id, 'id' => $transactionId, ]); } private function independentInsertUrl(int $transactionId): string { return route('business.financial-ledger.transactions.insert', [ 'company_id' => $this->company->id, 'id' => $transactionId, ]); } private function customColumnStoreUrl(): string { return route('business.financial-ledger.transactions.columns.store', [ 'company_id' => $this->company->id, ]); } private function customColumnDeleteUrl(int $columnId): string { return route('business.financial-ledger.transactions.columns.delete', [ 'company_id' => $this->company->id, 'id' => $columnId, ]); } private function yutuUser(): User { $user = user_model_class()::create([ 'name' => '玉兔', 'email' => 'manual-yutu-' . uniqid() . '@example.com', 'password' => 'local-test-password', 'locale' => 'zh-CN', 'enabled' => 1, 'landing_page' => 'dashboard', 'created_from' => 'core::test', ]); $user->companies()->attach($this->company->id); $user->roles()->attach(Role::where('name', 'user')->firstOrFail()->id); $user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); return $user; } 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); } private function addZhuJunxiangAccounts(): void { $connection = DB::connection('business_mirror'); $connection->table('fin_accounts')->insert([ [ 'company_name' => '朱俊祥', 'account_short' => '朱俊祥支付宝', 'account_type' => '支付宝', 'current_balance' => 100, ], [ 'company_name' => '朱俊祥', 'account_short' => '朱俊祥微信', 'account_type' => '个人微信', 'current_balance' => 50, ], ]); $connection->table('fin_account_opening')->insert([ [ 'account_short' => '朱俊祥支付宝', 'opening_date' => '2026-10-17', 'opening_balance' => 100, ], [ 'account_short' => '朱俊祥微信', 'opening_date' => '2026-10-17', 'opening_balance' => 50, ], ]); } public function test_transaction_page_exposes_account_specific_subpages_with_exact_filters(): void { $this->addZhuJunxiangAccounts(); $connection = DB::connection('business_mirror'); $connection->table('fin_transactions')->insert([ [ 'txn_date' => '2026-10-18', 'account_short' => '朱俊祥支付宝', 'detail' => '支付宝专属页收入', 'income' => 12, 'expense' => 0, 'approval_no' => 'ZJX-ALI-1', 'source' => 'workorder', 'source_value' => '朱俊祥支付宝', 'row_key' => 'zjx-alipay-view-1', ], [ 'txn_date' => '2026-10-19', 'account_short' => '朱俊祥微信', 'detail' => '微信专属页支出', 'income' => 0, 'expense' => 8, 'approval_no' => 'ZJX-WX-1', 'source' => 'dingtalk', 'source_value' => '朱俊祥微信', 'row_key' => 'zjx-wechat-view-1', ], [ 'txn_date' => '2026-10-20', 'account_short' => '手工账户甲', 'detail' => '不应出现在专属页', 'income' => 1, 'expense' => 0, 'approval_no' => 'OTHER-1', 'source' => 'manual', 'source_value' => null, 'row_key' => 'zjx-other-view-1', ], ]); $this->loginAs()->get($this->pageUrl() . '&txns_view=zhu-junxiang-alipay') ->assertOk() ->assertSee('data-ledger-transaction-view="zhu-junxiang-alipay"', false) ->assertSee('data-ledger-transaction-view="zhu-junxiang-wechat"', false) ->assertSee('独立账单页') ->assertSee('不进入账户总表、每日资金流水或其他页面') ->assertSee('支付宝专属页收入') ->assertDontSee('微信专属页支出') ->assertDontSee('不应出现在专属页'); $this->loginAs()->get($this->pageUrl() . '&txns_view=zhu-junxiang-wechat') ->assertOk() ->assertSee('微信专属页支出') ->assertDontSee('支付宝专属页收入') ->assertDontSee('不应出现在专属页'); $this->loginAs()->get($this->pageUrl()) ->assertOk() ->assertDontSee('支付宝专属页收入') ->assertDontSee('微信专属页支出') ->assertSee('不应出现在专属页'); } public function test_account_specific_manual_transaction_stays_inside_the_independent_statement(): void { $this->addZhuJunxiangAccounts(); $this->loginAs()->postJson( $this->storeUrl() . '?txns_view=zhu-junxiang-wechat', $this->payload([ 'idempotency_key' => '553ca003-8a20-4762-b1c9-1442b7f7d0b1', 'account_short' => '朱俊祥微信', 'detail' => '朱俊祥微信专属页手工支出', 'txn_date' => '2026-10-01', 'income' => 0, 'expense' => 20, 'balance' => 30, ]), ) ->assertCreated() ->assertJsonPath('summary.count', 1) ->assertJsonPath('summary.expense', 20) ->assertJsonPath('summary.current_balance', 30) ->assertJsonPath('daily_flow', null) ->assertJsonPath('account.current_balance', 30); $connection = DB::connection('business_mirror'); $this->assertDatabaseMissing('fin_daily_flows', [ 'account_short' => '朱俊祥微信', ], 'business_mirror'); $this->assertDatabaseHas('fin_accounts', [ 'account_short' => '朱俊祥微信', 'current_balance' => 50, ], 'business_mirror'); $transaction = $connection->table('fin_transactions') ->where('account_short', '朱俊祥微信') ->whereDate('txn_date', '2026-10-01') ->where('detail', '朱俊祥微信专属页手工支出') ->first(); $this->assertNotNull($transaction); $this->loginAs()->postJson($this->manualUpdateUrl((int) $transaction->id), [ 'txn_date' => '2026-09-30', 'account_short' => '朱俊祥微信', 'detail' => '独立账单历史日期', 'income' => 0, 'expense' => 20, 'balance' => 25, 'approval_no' => 'INDEPENDENT-EDIT-1', ]) ->assertOk() ->assertJsonPath('transaction.txn_date', '2026-09-30') ->assertJsonPath('message', '手工明细已保存到独立账单。'); } public function test_account_specific_page_explains_when_the_account_master_is_missing(): void { DB::connection('business_mirror')->table('fin_transactions')->insert([ 'txn_date' => '2026-10-18', 'account_short' => '朱俊祥微信', 'detail' => '缺少账户总表时的微信明细', 'income' => 12, 'expense' => 0, 'approval_no' => 'ZJX-WX-MISSING-1', 'source' => 'workorder', 'source_value' => '朱俊祥微信', 'row_key' => 'zjx-wechat-missing-account-1', ]); $this->loginAs()->get($this->pageUrl() . '&txns_view=zhu-junxiang-wechat') ->assertOk() ->assertSee('当前为“朱俊祥微信”独立账单页') ->assertDontSee('账户总表中尚未建立“朱俊祥微信”') ->assertSee('缺少账户总表时的微信明细'); } 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([ 'counterparty_info' => '138******01 / *甲', 'balance' => 125.5, 'payment_remark' => '10.01 甲客户', ])) ->assertCreated() ->assertJsonPath('row.source', FinanceLedgerTransactionWriter::MANUAL_SOURCE) ->assertJsonPath('row.account_short', '手工账户甲') ->assertJsonPath('row.counterparty_info', '138******01 / *甲') ->assertJsonPath('row.balance', 125.5) ->assertJsonPath('row.payment_remark', '10.01 甲客户') ->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' => '手工账户甲', 'counterparty_info' => '138******01 / *甲', 'balance' => 125.5, 'payment_remark' => '10.01 甲客户', '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'); $connection->table('fin_transactions')->insert([ 'txn_date' => '2026-10-18', 'account_short' => '朱俊祥支付宝', 'detail' => '独立月度账单明细', 'income' => 12, 'expense' => 0, 'balance' => 112, 'approval_no' => 'INDEPENDENT-1', 'source' => 'alipay-company', 'source_value' => 'ALI-INDEPENDENT', 'process_type' => '公司支付宝账务流水', 'row_key' => 'independent-statement-1', ]); $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'); $this->assertDatabaseHas('fin_transactions', [ 'account_short' => '朱俊祥支付宝', 'detail' => '独立月度账单明细', ], 'business_mirror'); } public function test_independent_statement_rows_survive_snapshot_replacement(): void { $connection = DB::connection('business_mirror'); $connection->table('fin_transactions')->insert([ 'txn_date' => '2026-10-18', 'account_short' => '朱俊祥微信', 'detail' => '独立微信月度账单', 'income' => 20, 'expense' => 0, 'balance' => 120, 'approval_no' => 'INDEPENDENT-WECHAT-1', 'source' => 'alipay-company', 'source_value' => 'WECHAT-INDEPENDENT', 'process_type' => '公司支付宝账务流水', 'row_key' => 'independent-wechat-statement-1', ]); $path = tempnam(sys_get_temp_dir(), 'ledger-snapshot-') . '.json'; file_put_contents($path, json_encode([ [ 'process' => '店铺提现', 'date' => '2026-10-19', 'process_no' => 'SNAPSHOT-1', 'account_short' => '手工账户甲', 'acct_name' => '手工账户甲', 'income' => 15, 'expense' => 0, 'detail' => '快照同步明细', ], ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); try { app(FinanceLedgerSnapshotImporter::class)->import($path, false); } finally { @unlink($path); } $this->assertDatabaseHas('fin_transactions', [ 'account_short' => '朱俊祥微信', 'detail' => '独立微信月度账单', ], 'business_mirror'); $this->assertDatabaseHas('fin_transactions', [ 'detail' => '快照同步明细', 'source' => 'shop', ], 'business_mirror'); } public function test_yutu_can_edit_every_imported_independent_statement_field_and_custom_value(): void { $this->addZhuJunxiangAccounts(); $yutu = $this->yutuUser(); $connection = DB::connection('business_mirror'); $transactionId = (int) $connection->table('fin_transactions')->insertGetId([ 'txn_date' => '2026-10-02', 'account_short' => '朱俊祥支付宝', 'counterparty_info' => '原对方信息', 'detail' => '原支付宝明细', 'income' => 12, 'expense' => 0, 'balance' => 112, 'approval_no' => 'ALI-IMPORTED-EDIT', 'payment_remark' => '原备注', 'source' => 'alipay-company', 'source_value' => 'ALI-ACCOUNT-NO', 'process_type' => '公司支付宝账务流水', 'row_key' => 'independent-imported-edit-row', ]); $column = $this->loginAs($yutu)->postJson($this->customColumnStoreUrl(), [ 'account_short' => '朱俊祥支付宝', 'field_name' => '经办人', ])->assertCreated()->json('field'); $this->loginAs($yutu)->postJson($this->independentUpdateUrl($transactionId), [ 'txn_date' => '2026-10-03', 'counterparty_info' => '修改后的对方信息', 'detail' => '修改后的导入明细', 'income' => 0, 'expense' => 8.88, 'balance' => 103.12, 'approval_no' => 'ALI-EDITED', 'payment_remark' => '修改后备注', 'source' => 'manual', 'custom_values' => [ (string) $column['field_key'] => '财务玉兔', ], ]) ->assertOk() ->assertJsonPath('transaction.account_short', '朱俊祥支付宝') ->assertJsonPath('transaction.detail', '修改后的导入明细') ->assertJsonPath('transaction.source', 'manual') ->assertJsonPath('transaction.custom_values.' . $column['field_key'], '财务玉兔'); $this->assertDatabaseHas('fin_transactions', [ 'id' => $transactionId, 'txn_date' => '2026-10-03', 'counterparty_info' => '修改后的对方信息', 'detail' => '修改后的导入明细', 'expense' => 8.88, 'balance' => 103.12, 'approval_no' => 'ALI-EDITED', 'payment_remark' => '修改后备注', 'source' => 'manual', 'updated_by' => '玉兔', ], 'business_mirror'); $this->assertDatabaseHas('fin_transaction_custom_values', [ 'custom_field_id' => $column['id'], 'transaction_id' => $transactionId, 'value' => '财务玉兔', ], 'business_mirror'); $this->assertDatabaseHas('fin_ledger_edit_events', [ 'entity_type' => 'stmt_txn_update', 'user_name' => '玉兔', ], 'business_mirror'); $this->assertDatabaseMissing('fin_daily_flows', [ 'account_short' => '朱俊祥支付宝', ], 'business_mirror'); $this->loginAs($yutu)->get( $this->pageUrl() . '&txns_view=zhu-junxiang-alipay', ) ->assertOk() ->assertSee('修改后的导入明细') ->assertSee('财务玉兔') ->assertSee($this->independentUpdateUrl($transactionId), false) ->assertSee('data-ledger-manual-edit', false) ->assertSee('data-ledger-statement-column-delete', false); } public function test_imported_independent_statement_row_is_soft_deleted_and_disappears_from_summary(): void { $this->addZhuJunxiangAccounts(); $yutu = $this->yutuUser(); $connection = DB::connection('business_mirror'); $transactionId = (int) $connection->table('fin_transactions')->insertGetId([ 'txn_date' => '2026-10-04', 'account_short' => '朱俊祥微信', 'detail' => '待删除导入明细', 'income' => 20, 'expense' => 0, 'balance' => 70, 'approval_no' => 'WX-DELETE-IMPORTED', 'source' => 'wechat-statement', 'source_value' => '朱俊祥微信', 'process_type' => '公司微信收支流水', 'row_key' => 'independent-imported-delete-row', ]); $this->loginAs($yutu)->deleteJson($this->independentDeleteUrl($transactionId)) ->assertOk() ->assertJsonPath('deleted', true); $this->assertNotNull($connection->table('fin_transactions') ->where('id', $transactionId) ->value('deleted_at')); $this->assertDatabaseHas('fin_ledger_edit_events', [ 'entity_type' => 'stmt_txn_delete', 'user_name' => '玉兔', ], 'business_mirror'); $this->loginAs($yutu)->get( $this->pageUrl() . '&txns_view=zhu-junxiang-wechat', ) ->assertOk() ->assertDontSee('待删除导入明细') ->assertDontSee($this->independentDeleteUrl($transactionId), false); } public function test_independent_statement_custom_columns_are_separate_and_deletable(): void { $this->addZhuJunxiangAccounts(); $yutu = $this->yutuUser(); $alipayColumn = $this->loginAs($yutu)->postJson($this->customColumnStoreUrl(), [ 'account_short' => '朱俊祥支付宝', 'field_name' => '支付宝用途', ])->assertCreated()->json('field'); $wechatColumn = $this->loginAs($yutu)->postJson($this->customColumnStoreUrl(), [ 'account_short' => '朱俊祥微信', 'field_name' => '微信经办人', ])->assertCreated()->json('field'); $this->loginAs($yutu)->get( $this->pageUrl() . '&txns_view=zhu-junxiang-alipay', ) ->assertOk() ->assertSee('支付宝用途') ->assertDontSee('微信经办人'); $this->loginAs($yutu)->get( $this->pageUrl() . '&txns_view=zhu-junxiang-wechat', ) ->assertOk() ->assertSee('微信经办人') ->assertDontSee('支付宝用途'); $connection = DB::connection('business_mirror'); $transactionId = (int) $connection->table('fin_transactions')->insertGetId([ 'txn_date' => '2026-10-05', 'account_short' => '朱俊祥支付宝', 'detail' => '自定义列删除回归', 'income' => 1, 'expense' => 0, 'approval_no' => 'CUSTOM-DELETE-ROW', 'source' => 'manual', 'row_key' => 'custom-column-delete-row', ]); $connection->table('fin_transaction_custom_values')->insert([ 'custom_field_id' => $alipayColumn['id'], 'transaction_id' => $transactionId, 'value' => '待删列数据', ]); $this->loginAs($yutu)->deleteJson($this->customColumnDeleteUrl((int) $alipayColumn['id'])) ->assertOk() ->assertJsonPath('deleted', true); $this->assertDatabaseMissing('fin_transaction_custom_fields', [ 'id' => $alipayColumn['id'], ], 'business_mirror'); $this->assertDatabaseMissing('fin_transaction_custom_values', [ 'custom_field_id' => $alipayColumn['id'], ], 'business_mirror'); $this->assertDatabaseHas('fin_transaction_custom_fields', [ 'id' => $wechatColumn['id'], ], 'business_mirror'); } public function test_independent_statement_columns_can_be_inserted_left_and_right_in_batches(): void { $this->addZhuJunxiangAccounts(); $yutu = $this->yutuUser(); $first = $this->loginAs($yutu)->postJson($this->customColumnStoreUrl(), [ 'account_short' => '朱俊祥支付宝', 'field_name' => '列甲', ])->assertCreated()->json('field'); $second = $this->loginAs($yutu)->postJson($this->customColumnStoreUrl(), [ 'account_short' => '朱俊祥支付宝', 'field_name' => '列乙', ])->assertCreated()->json('field'); $left = $this->loginAs($yutu)->postJson($this->customColumnStoreUrl(), [ 'account_short' => '朱俊祥支付宝', 'field_names' => ['列丙', '列丁'], 'anchor_field_id' => $second['id'], 'position' => 'left', ])->assertCreated() ->assertJsonCount(2, 'fields'); $this->assertSame( ['列甲', '列丙', '列丁', '列乙'], DB::connection('business_mirror')->table('fin_transaction_custom_fields') ->where('account_short', '朱俊祥支付宝') ->orderBy('sort_order') ->orderBy('id') ->pluck('field_name') ->all(), ); $third = $left->json('fields.0'); $this->loginAs($yutu)->postJson($this->customColumnStoreUrl(), [ 'account_short' => '朱俊祥支付宝', 'field_names' => ['列戊'], 'anchor_field_id' => $third['id'], 'position' => 'right', ])->assertCreated(); $this->assertSame( ['列甲', '列丙', '列戊', '列丁', '列乙'], DB::connection('business_mirror')->table('fin_transaction_custom_fields') ->where('account_short', '朱俊祥支付宝') ->orderBy('sort_order') ->orderBy('id') ->pluck('field_name') ->all(), ); $this->assertDatabaseHas('fin_ledger_edit_events', [ 'entity_type' => 'statement_col_add', 'user_name' => '玉兔', ], 'business_mirror'); } public function test_independent_statement_rows_can_be_inserted_above_and_below_with_quantity(): void { $this->addZhuJunxiangAccounts(); $yutu = $this->yutuUser(); $connection = DB::connection('business_mirror'); $lowerId = (int) $connection->table('fin_transactions')->insertGetId([ 'txn_date' => '2026-10-05', 'account_short' => '朱俊祥支付宝', 'detail' => '原始下方行', 'income' => 1, 'expense' => 0, 'source' => 'manual', 'row_key' => 'insert-row-lower', ]); $anchorId = (int) $connection->table('fin_transactions')->insertGetId([ 'txn_date' => '2026-10-05', 'account_short' => '朱俊祥支付宝', 'detail' => '原始定位行', 'income' => 2, 'expense' => 0, 'source' => 'manual', 'row_key' => 'insert-row-anchor', ]); $upperId = (int) $connection->table('fin_transactions')->insertGetId([ 'txn_date' => '2026-10-05', 'account_short' => '朱俊祥支付宝', 'detail' => '原始上方行', 'income' => 3, 'expense' => 0, 'source' => 'manual', 'row_key' => 'insert-row-upper', ]); $above = $this->loginAs($yutu)->postJson($this->independentInsertUrl($anchorId), [ 'position' => 'above', 'count' => 2, ])->assertCreated() ->assertJsonCount(2, 'rows') ->json('rows'); $below = $this->loginAs($yutu)->postJson($this->independentInsertUrl($anchorId), [ 'position' => 'below', 'count' => 1, ])->assertCreated() ->assertJsonCount(1, 'rows') ->json('rows'); $orderedIds = $connection->table('fin_transactions') ->where('account_short', '朱俊祥支付宝') ->whereNull('deleted_at') ->orderByDesc('txn_date') ->orderByRaw('COALESCE(display_order, id) DESC') ->orderByDesc('id') ->pluck('id') ->map(static fn (mixed $id): int => (int) $id) ->all(); $this->assertSame([ $upperId, (int) $above[1]['id'], (int) $above[0]['id'], $anchorId, (int) $below[0]['id'], $lowerId, ], $orderedIds); $this->assertCount(3, $connection->table('fin_transactions') ->whereIn('id', [ (int) $above[0]['id'], (int) $above[1]['id'], (int) $below[0]['id'], ]) ->where('source', 'manual') ->where('process_type', '手工插入') ->whereNull('detail') ->get()); $this->assertDatabaseHas('fin_ledger_edit_events', [ 'entity_type' => 'stmt_txn_insert', 'user_name' => '玉兔', ], 'business_mirror'); } public function test_independent_statement_page_exposes_excel_style_insert_controls(): void { $this->addZhuJunxiangAccounts(); $yutu = $this->yutuUser(); $this->loginAs($yutu)->postJson($this->customColumnStoreUrl(), [ 'account_short' => '朱俊祥支付宝', 'field_name' => 'Excel 插入列', ])->assertCreated(); $connection = DB::connection('business_mirror'); $connection->table('fin_transactions')->insert([ 'txn_date' => '2026-10-05', 'account_short' => '朱俊祥支付宝', 'detail' => 'Excel 插入行', 'income' => 1, 'expense' => 0, 'source' => 'manual', 'row_key' => 'excel-insert-ui-row', ]); $this->loginAs($yutu)->get( $this->pageUrl() . '&txns_view=zhu-junxiang-alipay', ) ->assertOk() ->assertSee('data-ledger-excel-insert-menu', false) ->assertSee('data-ledger-excel-row-trigger', false) ->assertSee('data-ledger-excel-column-trigger', false) ->assertSee('在上方插入') ->assertSee('在下方插入') ->assertSee('data-ledger-statement-column-names', false) ->assertSee('data-ledger-excel-insert-count', false); } public function test_imported_rows_outside_independent_pages_remain_read_only(): void { $connection = DB::connection('business_mirror'); $transactionId = (int) $connection->table('fin_transactions')->insertGetId([ 'txn_date' => '2026-10-06', 'account_short' => '手工账户甲', 'detail' => '普通导入明细只读', 'income' => 5, 'expense' => 0, 'approval_no' => 'NORMAL-READONLY', 'source' => 'workorder', 'source_value' => 'NORMAL-READONLY-VALUE', 'row_key' => 'normal-imported-readonly-row', ]); $this->loginAs($this->yutuUser())->get($this->pageUrl()) ->assertOk() ->assertSee('普通导入明细只读') ->assertDontSee($this->independentUpdateUrl($transactionId), false) ->assertDontSee($this->independentDeleteUrl($transactionId), false); } 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_yutu_can_add_edit_and_delete_manual_transactions_without_ledger_admin_role(): void { $yutu = $this->yutuUser(); $this->loginAs($yutu)->postJson($this->storeUrl(), $this->payload()) ->assertCreated() ->assertJsonPath('row.detail', '手工补录测试'); $transactionId = (int) DB::connection('business_mirror')->table('fin_transactions') ->where('source', FinanceLedgerTransactionWriter::MANUAL_SOURCE) ->value('id'); $this->loginAs($yutu)->get($this->pageUrl()) ->assertOk() ->assertSee('新增手工明细') ->assertSee('data-ledger-manual-edit', false) ->assertSee('data-ledger-manual-delete', false) ->assertSee($this->manualUpdateUrl($transactionId), false) ->assertSee($this->manualDeleteUrl($transactionId), false); $this->loginAs($yutu)->postJson($this->manualUpdateUrl($transactionId), [ 'txn_date' => '2026-10-19', 'account_short' => '手工账户乙', 'detail' => '玉兔修正后的手工明细', 'income' => 0, 'expense' => 18.60, 'approval_no' => 'YT-EDIT-1', ]) ->assertOk() ->assertJsonPath('transaction.account_short', '手工账户乙') ->assertJsonPath('transaction.detail', '玉兔修正后的手工明细') ->assertJsonPath('transaction.expense', 18.6); $this->assertDatabaseHas('fin_transactions', [ 'id' => $transactionId, 'txn_date' => '2026-10-19', 'account_short' => '手工账户乙', 'detail' => '玉兔修正后的手工明细', 'expense' => 18.6, 'approval_no' => 'YT-EDIT-1', 'updated_by' => '玉兔', ], 'business_mirror'); $this->assertDatabaseHas('fin_ledger_edit_events', [ 'entity_type' => 'manual_transaction_update', 'user_name' => '玉兔', ], 'business_mirror'); $this->assertDatabaseHas('fin_daily_flows', [ 'flow_date' => '2026-10-19', 'account_short' => '手工账户乙', 'outflow' => 18.6, 'closing_balance' => -18.6, ], 'business_mirror'); $this->loginAs($yutu)->deleteJson($this->manualDeleteUrl($transactionId)) ->assertOk() ->assertJsonPath('deleted', true); $this->assertDatabaseMissing('fin_transactions', [ 'id' => $transactionId, ], 'business_mirror'); $this->assertDatabaseHas('fin_ledger_edit_events', [ 'entity_type' => 'manual_transaction_delete', 'user_name' => '玉兔', ], 'business_mirror'); $this->assertSame(0.0, (float) DB::connection('business_mirror')->table('fin_accounts') ->where('account_short', '手工账户乙') ->value('current_balance')); } 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(); } }