CaiWuXiTong/tests/Feature/Business/FinanceLedgerManualTransactionTest.php
shanshi 2c1bea34e0 feat(财务台账): 支持逐行修改动账账户关联 (#456)
动账明细账户简称支持输入搜索和逐行修改,映射、流水及余额同步重算。
2026-10-22 11:49:52 +08:00

341 lines
14 KiB
PHP

<?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 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();
}
}