*/ private array $temporaryFiles = []; protected function setUp(): void { parent::setUp(); DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->delete(); $mirror = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror')); if (! $mirror->hasTable('taobao_alipay_bill_rows')) { $mirror->create('taobao_alipay_bill_rows', function (Blueprint $table): void { $table->string('record_code', 191)->primary(); $table->string('store_name')->nullable(); $table->string('alipay_account')->nullable(); $table->dateTime('occurred_at'); $table->decimal('balance_amount', 20, 6)->default(0); }); } else { DB::connection('business_mirror')->table('taobao_alipay_bill_rows')->delete(); } DB::connection()->table('business_store_receiving_accounts')->delete(); } protected function tearDown(): void { foreach ($this->temporaryFiles as $path) { if (is_file($path)) { unlink($path); } } parent::tearDown(); } public function test_finance_bill_fills_only_missing_jushuitan_store_and_marks_source(): void { Carbon::setTestNow('2026-10-24 10:00:00'); try { $jushuitanStore = $this->store('天猫', '聚水潭已有店'); $missingStore = $this->store('天猫', '财务账单补录店'); $this->insertSnapshot($jushuitanStore->erp_store_name, 10000, 'jushuitan'); $finance = Mockery::mock(FinanceBillBalanceRepository::class); $finance->shouldReceive('latestBalances') ->twice() ->with('taobao', '2026-10-23', [$missingStore->erp_store_name]) ->andReturn([$this->financeRow($missingStore->erp_store_name, 12345.67)]); $this->app->instance(FinanceBillBalanceRepository::class, $finance); $fallback = $this->app->make(FinanceBillShopBalanceFallback::class); $first = $fallback->fillMissing('2026-10-23', 'taobao'); $second = $fallback->fillMissing('2026-10-23', 'taobao'); $this->assertSame(1, $first['missing']); $this->assertSame(1, $first['resolved']); $this->assertSame(1, $first['filled']); $this->assertSame(1, $second['filled']); $this->assertSame(2, DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', '2026-10-23') ->where('source_scope', 'taobao') ->count()); $this->assertSame(1, DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE) ->count()); $matches = $this->app->make(JushuitanShopBalanceRepository::class)->matchesForStores( [$jushuitanStore, $missingStore], '2026-10-23', ); $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, $matches[$jushuitanStore->id]['data_source']); $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE, $matches[$missingStore->id]['data_source']); $this->assertSame(12345.67, $matches[$missingStore->id]['balance']); } finally { Carbon::setTestNow(); } } public function test_existing_jushuitan_balance_removes_stale_finance_fallback(): void { $store = $this->store('京东', '聚水潭恢复店'); $this->insertSnapshot($store->erp_store_name, 8000, 'jushuitan'); $this->insertSnapshot($store->erp_store_name, 7000, 'finance'); $finance = Mockery::mock(FinanceBillBalanceRepository::class); $finance->shouldNotReceive('latestBalances'); $this->app->instance(FinanceBillBalanceRepository::class, $finance); $result = $this->app->make(FinanceBillShopBalanceFallback::class) ->fillMissing('2026-10-23', 'jd-wallet'); $this->assertSame(0, $result['missing']); $this->assertSame(0, DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE) ->count()); $match = $this->app->make(JushuitanShopBalanceRepository::class) ->matchesForStores([$store], '2026-10-23')[$store->id]; $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, $match['data_source']); $this->assertSame(8000.0, $match['balance']); } public function test_pinduoduo_without_cumulative_balance_is_not_filled_from_transactions(): void { $store = $this->store('拼多多', '拼多多无余额店'); $finance = Mockery::mock(FinanceBillBalanceRepository::class); $finance->shouldNotReceive('latestBalances'); $this->app->instance(FinanceBillBalanceRepository::class, $finance); $result = $this->app->make(FinanceBillShopBalanceFallback::class) ->fillMissing('2026-10-23', 'pinduoduo'); $this->assertFalse($result['supported']); $this->assertSame('finance_bill_has_no_account_balance', $result['reason']); $this->assertSame(1, $result['missing']); $this->assertSame(0, $result['filled']); $this->assertSame(0, DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('normalized_store_name', JushuitanShopBalanceRepository::normalizeStoreName($store->erp_store_name)) ->count()); } public function test_finance_source_failure_keeps_previous_fallback_rows(): void { $store = $this->store('天猫', '财务源失败保留店'); $this->insertSnapshot($store->erp_store_name, 6000, 'finance'); $finance = Mockery::mock(FinanceBillBalanceRepository::class); $finance->shouldReceive('latestBalances') ->once() ->with('taobao', '2026-10-23', [$store->erp_store_name]) ->andThrow(new RuntimeException('source unavailable')); $this->app->instance(FinanceBillBalanceRepository::class, $finance); try { $this->app->make(FinanceBillShopBalanceFallback::class) ->fillMissing('2026-10-23', 'taobao'); $this->fail('Expected source exception.'); } catch (RuntimeException $exception) { $this->assertSame('source unavailable', $exception->getMessage()); } $this->assertSame(6000.0, (float) DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE) ->value('balance')); } public function test_import_command_fills_missing_store_from_finance_bill_and_marks_sources(): void { $jushuitanStore = $this->store('天猫', '聚水潭自动补录已有店'); $missingStore = $this->store('天猫', '财务账单自动补录店'); $file = $this->jushuitanJson([$jushuitanStore->erp_store_name]); $this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-a', '2026-10-22 20:00:00', 12000); $this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-a', '2026-10-23 23:59:59', 13500); $this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-b', '2026-10-23 23:59:30', 500); $this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-a', '2026-10-24 00:00:00', 99999); $financeBalances = $this->app->make(FinanceBillBalanceRepository::class) ->latestBalances('taobao', '2026-10-23', [$missingStore->erp_store_name]); $this->assertCount(1, $financeBalances); $this->assertSame(14000.0, $financeBalances[0]['balance']); $this->artisan('business:import-jushuitan-shop-balances', [ 'path' => $file, '--balance-date' => '2026-10-23', '--scope' => 'taobao', ])->assertExitCode(0); $matches = $this->app->make(JushuitanShopBalanceRepository::class)->matchesForStores( [$jushuitanStore, $missingStore], '2026-10-23', ); $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, $matches[$jushuitanStore->id]['data_source']); $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE, $matches[$missingStore->id]['data_source']); $this->assertSame(14000.0, $matches[$missingStore->id]['balance']); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'fund-management', 'page' => 'withdrawal-management', 'balance_date' => '2026-10-23', ])) ->assertOk() ->assertSeeText('财务账单补录 1 条') ->assertSee('withdrawal-balance-source is-finance', false) ->assertSee( 'title="数据来源:财务系统;店铺:天猫-财务账单自动补录店;余额日期:2026-10-23"', false, ); } public function test_jushuitan_import_preserves_finance_fallback_rows(): void { $financeStore = $this->store('天猫', '财务补录保留店'); $jushuitanStore = $this->store('天猫', '聚水潭刷新店'); $this->insertSnapshot($financeStore->erp_store_name, 8800, 'finance'); $file = $this->jushuitanJson([$jushuitanStore->erp_store_name]); $result = $this->app->make(JushuitanShopBalanceImporter::class) ->import($file, '2026-10-23', 'taobao'); $this->assertSame(0, $result['replaced']); $this->assertSame(1, DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', '2026-10-23') ->where('source_scope', 'taobao') ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE) ->count()); $this->assertSame(1, DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', '2026-10-23') ->where('source_scope', 'taobao') ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN) ->count()); } private function store(string $platform, string $name): StoreReceivingAccount { return StoreReceivingAccount::query()->create([ 'company_id' => $this->company->id, 'store_platform' => $platform, 'erp_store_name' => $platform . '-' . $name, 'shop_name' => $platform . '-' . $name, 'store_entity_name' => '余额回填测试主体', ]); } private function insertSnapshot(string $storeName, float $balance, string $dataSource): void { $normalized = JushuitanShopBalanceRepository::normalizeStoreName($storeName); DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([ 'balance_date' => '2026-10-23', 'source_scope' => str_starts_with($storeName, '京东') ? 'jd-wallet' : 'taobao', 'data_source' => $dataSource, 'source_platform' => JushuitanShopBalanceRepository::sourcePlatform($storeName), 'store_name' => $storeName, 'normalized_store_name' => $normalized, 'platform_account_id' => 'fixture-account', 'balance' => $balance, 'source_row_key' => hash('sha256', $dataSource . '|' . $storeName), 'source_file_name' => 'fixture-' . $dataSource . '.json', 'source_sha256' => hash('sha256', $dataSource . '|' . $storeName . '|sha'), 'imported_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); } /** @param list $storeNames */ private function jushuitanJson(array $storeNames): string { $path = sys_get_temp_dir() . '/jst-finance-fallback-' . uniqid('', true) . '.json'; $this->temporaryFiles[] = $path; file_put_contents($path, json_encode([ 'fldHeads' => [ ['title' => '店铺', 'fld' => '店铺'], ['title' => '平台实际期末余额', 'fld' => '平台实际期末余额'], ], 'dataList' => array_map( static fn (string $storeName): array => [ '店铺' => $storeName, '平台实际期末余额' => 5000, ], $storeNames, ), ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); return $path; } private function insertTaobaoBillRow( string $storeName, string $account, string $occurredAt, float $balance, ): void { DB::connection('business_mirror')->table('taobao_alipay_bill_rows')->insert([ 'record_code' => 'record-' . uniqid('', true), 'store_name' => $storeName, 'alipay_account' => $account, 'occurred_at' => $occurredAt, 'balance_amount' => $balance, ]); } /** @return array */ private function financeRow(string $storeName, float $balance): array { return [ 'source_scope' => 'taobao', 'source_platform' => '天猫', 'store_name' => $storeName, 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName), 'platform_account_id' => '20880001', 'balance' => $balance, 'occurred_at' => '2026-10-23 20:00:00', 'source_row_key' => hash('sha256', 'finance|' . $storeName), 'source_file_name' => '财务系统/店铺收款账户账单/淘系支付宝', ]; } }