user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); } protected function tearDown(): void { Carbon::setTestNow(); parent::tearDown(); } public function test_shop_balance_uses_latest_balance_on_or_before_previous_night(): void { Carbon::setTestNow('2026-10-24 09:00:00'); $connection = DB::connection('business_mirror'); $connection->table('fin_shops')->insert([ 'shop_name' => '京东-余额测试店', 'platform' => '京东', 'account_short' => '余额测试账户', 'shop_balance' => 1, 'created_at' => now(), 'updated_at' => now(), ]); $this->insertBalance($connection, '2026-10-22', '京东-余额测试店', 222.22); $this->insertBalance($connection, '2026-10-23', '京东-余额测试店', 333.33); $this->insertBalance($connection, '2026-10-24', '京东-余额测试店', 444.44); $result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23'); $this->assertSame('2026-10-23', $result['date']); $this->assertSame(1, $result['matched']); $shop = $connection->table('fin_shops')->where('shop_name', '京东-余额测试店')->first(); $this->assertSame(333.33, (float) $shop->shop_balance); $this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10)); } public function test_balance_sync_adds_confirmed_store_that_is_missing_from_shop_ledger(): void { Carbon::setTestNow('2026-10-24 09:00:00'); StoreReceivingAccount::withoutGlobalScopes()->create([ 'company_id' => $this->company->id, 'store_platform' => '抖音', 'store_name' => '抖音-待补充店铺', 'store_entity_name' => '补充主体有限公司', 'receiving_method' => '银行卡', ]); $this->insertBalance( DB::connection('business_mirror'), '2026-10-23', '抖音-待补充店铺', 765.43, '头条放心购', ); $result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23'); $this->assertSame(1, $result['added']); $shop = DB::connection('business_mirror')->table('fin_shops') ->where('shop_name', '抖音-待补充店铺') ->first(); $this->assertNotNull($shop); $this->assertSame('抖音小店', $shop->platform); $this->assertSame('补充主体有限公司', $shop->company_name); $this->assertNull($shop->account_short); $this->assertSame(765.43, (float) $shop->shop_balance); $this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10)); } public function test_balance_sync_adds_missing_store_even_when_receiving_account_is_unknown(): void { Carbon::setTestNow('2026-10-24 09:00:00'); $this->insertBalance( DB::connection('business_mirror'), '2026-10-23', '京喜-待确认店铺', 432.10, '京喜', ); $result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23'); $this->assertSame(1, $result['added']); $shop = DB::connection('business_mirror')->table('fin_shops') ->where('shop_name', '京喜-待确认店铺') ->first(); $this->assertNotNull($shop); $this->assertSame('京喜企业店', $shop->platform); $this->assertSame('待确认', $shop->status); $this->assertNull($shop->company_name); $this->assertNull($shop->account_short); $this->assertNull($shop->payout_account); $this->assertSame(432.10, (float) $shop->shop_balance); $this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10)); $this->assertStringContainsString('待确认回款账户', (string) $shop->remark); } public function test_rebuild_clears_stale_shop_ledger_and_reloads_latest_snapshot(): void { Carbon::setTestNow('2026-10-26 09:00:00'); $connection = DB::connection('business_mirror'); $connection->table('fin_shops')->insert([ 'shop_name' => '历史脏数据店', 'platform' => '天猫', 'shop_balance' => 99, 'created_at' => now(), 'updated_at' => now(), ]); StoreReceivingAccount::withoutGlobalScopes()->create([ 'company_id' => $this->company->id, 'store_platform' => '拼多多', 'store_name' => '拼多多-台账重建店', 'store_entity_name' => '台账重建主体有限公司', 'withdrawal_account_name' => '台账重建回款户', 'bank_card_last_four' => '7788', 'store_status' => '正常', ]); $connection->table('fin_account_map')->insert([ 'source' => 'shop', 'source_value' => '拼多多-台账重建店', 'account_short' => '重建账户', 'created_at' => now(), 'updated_at' => now(), ]); $this->insertBalance( $connection, '2026-10-25', '拼多多-台账重建店', 1588.66, '拼多多', ); $result = app(FinanceLedgerShopDataSynchronizer::class) ->syncShopBalances('2026-10-25', true); $this->assertSame(1, $result['rebuilt']); $this->assertSame(1, $result['matched']); $this->assertDatabaseMissing('fin_shops', ['shop_name' => '历史脏数据店'], 'business_mirror'); $shop = $connection->table('fin_shops')->first(); $this->assertSame('拼多多-台账重建店', $shop->shop_name); $this->assertSame('拼多多', $shop->platform); $this->assertSame('台账重建主体有限公司', $shop->company_name); $this->assertSame('重建账户', $shop->account_short); $this->assertSame('在营', $shop->status); $this->assertSame('台账重建回款户 7788', $shop->payout_account); $this->assertSame(1588.66, (float) $shop->shop_balance); $this->assertSame('2026-10-25', substr((string) $shop->shop_balance_date, 0, 10)); } public function test_rebuild_without_balance_snapshot_keeps_existing_shop_ledger(): void { $connection = DB::connection('business_mirror'); $connection->table('fin_shops')->insert([ 'shop_name' => '保留店铺', 'platform' => '天猫', 'shop_balance' => 88, 'created_at' => now(), 'updated_at' => now(), ]); $result = app(FinanceLedgerShopDataSynchronizer::class) ->syncShopBalances('2026-10-25', true); $this->assertSame(0, $result['rebuilt']); $this->assertDatabaseHas('fin_shops', [ 'shop_name' => '保留店铺', 'shop_balance' => 88, ], 'business_mirror'); } public function test_withdrawal_rows_are_mapped_to_shop_account_income_and_are_idempotent(): void { Carbon::setTestNow('2026-10-24 09:00:00'); $connection = DB::connection('business_mirror'); $connection->table('fin_shops')->insert([ 'shop_name' => '京东-提现测试店', 'platform' => '京东', 'account_short' => '提现测试账户', 'created_at' => now(), 'updated_at' => now(), ]); $this->bindWithdrawalRows([ [ 'record_code' => 'JD-WITHDRAW-1', 'store_name' => '京东-提现测试店', 'platform' => '京东', 'bill_item' => '提现', 'finance_remark' => '提现', 'occurred_at' => '2026-10-23 20:00:00', 'income' => '0.00', 'expense' => '123.45', ], ], 2); $synchronizer = app(FinanceLedgerShopDataSynchronizer::class); $first = $synchronizer->sync('2026-10-23', '2026-10-23', false); $second = $synchronizer->sync('2026-10-23', '2026-10-23', false); $this->assertSame(1, $first['withdrawals']['written']); $this->assertSame(1, $second['withdrawals']['written']); $this->assertDatabaseCount('fin_transactions', 1, 'business_mirror'); $transaction = $connection->table('fin_transactions')->first(); $this->assertSame('shop_bill', $transaction->source); $this->assertSame('提现测试账户', $transaction->account_short); $this->assertSame('店铺提现', $transaction->process_type); $this->assertSame(123.45, (float) $transaction->income); $this->assertSame(0.0, (float) $transaction->expense); } public function test_withdrawal_is_skipped_when_same_cashflow_was_already_synced_from_upstream(): void { Carbon::setTestNow('2026-10-24 09:00:00'); $connection = DB::connection('business_mirror'); $connection->table('fin_shops')->insert([ 'shop_name' => '京东-重复提现店', 'platform' => '京东', 'account_short' => '重复提现账户', 'created_at' => now(), 'updated_at' => now(), ]); $connection->table('fin_transactions')->insert([ 'txn_date' => '2026-10-23', 'account_short' => '重复提现账户', 'detail' => '上游已同步提现', 'income' => 88.00, 'expense' => 0, 'approval_no' => 'UPSTREAM-1', 'source' => 'shop', 'source_value' => '京东-重复提现店', 'process_type' => '店铺提现', 'row_key' => hash('sha256', 'upstream-duplicate'), 'created_at' => now(), 'updated_at' => now(), ]); $this->bindWithdrawalRows([ [ 'record_code' => 'JD-WITHDRAW-2', 'store_name' => '京东-重复提现店', 'platform' => '京东', 'bill_item' => '提现', 'finance_remark' => '提现', 'occurred_at' => '2026-10-23 21:00:00', 'income' => '0.00', 'expense' => '88.00', ], ]); $result = app(FinanceLedgerShopDataSynchronizer::class)->sync('2026-10-23', '2026-10-23', false); $this->assertSame(0, $result['withdrawals']['written']); $this->assertSame(1, $result['withdrawals']['upstream_duplicates']); $this->assertSame(0, $connection->table('fin_transactions')->where('source', 'shop_bill')->count()); } public function test_full_upstream_replacement_preserves_generated_shop_withdrawals(): void { $connection = DB::connection('business_mirror'); $connection->table('fin_transactions')->insert([ 'txn_date' => '2026-10-23', 'account_short' => '保留提现账户', 'detail' => '页面生成提现', 'income' => 50, 'expense' => 0, 'approval_no' => 'JD:KEEP-1', 'source' => FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE, 'source_value' => '京东-保留提现店', 'process_type' => '店铺提现', 'row_key' => hash('sha256', 'keep-shop-withdrawal'), 'created_at' => now(), 'updated_at' => now(), ]); app(FinanceLedgerTransactionWriter::class)->replace([ [ 'txn_date' => '2026-10-23', 'account_short' => '上游账户', 'detail' => '上游动账', 'income' => 10, 'expense' => 0, 'approval_no' => 'UPSTREAM-2', 'source' => 'dingtalk', 'source_value' => '上游来源', 'process_type' => '费用', ], ], false); $this->assertDatabaseHas('fin_transactions', [ 'source' => FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE, 'approval_no' => 'JD:KEEP-1', ], 'business_mirror'); $this->assertDatabaseHas('fin_transactions', [ 'source' => 'dingtalk', 'approval_no' => 'UPSTREAM-2', ], 'business_mirror'); } private function insertBalance( $connection, string $date, string $storeName, float $balance, string $platform = '京东', ): void { $connection->table(JushuitanShopBalanceRepository::TABLE)->insert([ 'balance_date' => $date, 'source_scope' => 'all', 'source_platform' => $platform, 'store_name' => $storeName, 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName), 'platform_account_id' => null, 'balance' => $balance, 'source_row_key' => hash('sha256', $date . '|' . $storeName . '|' . $balance), 'source_file_name' => 'fixture.xlsx', 'source_sha256' => hash('sha256', 'fixture'), 'imported_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); } private function bindWithdrawalRows(array $rows, int $calls = 1): void { $billing = Mockery::mock(BillingDetailsPageData::class); $billing->shouldReceive('exportFilters') ->times($calls) ->andReturnUsing(static fn (array $input): array => $input + ['remark' => ['提现']]); $billing->shouldReceive('exportColumns') ->times($calls) ->andReturn([ 'record_code' => ['记录编码', 'text'], 'store_name' => ['店铺', 'text'], 'finance_remark' => ['财务备注', 'text'], 'bill_item' => ['账单项目', 'tag'], 'occurred_at' => ['发生时间', 'text'], 'income' => ['收入金额', 'num'], 'expense' => ['支出金额', 'num'], ]); $billing->shouldReceive('exportRows') ->times($calls) ->andReturnUsing(static function () use ($rows): \Generator { yield from $rows; }); app()->instance(BillingDetailsPageData::class, $billing); } }