service(function (string $platform, string $start, string $end) use (&$seenPeriods): array { $seenPeriods[] = [$platform, $start, $end]; $index = array_search($platform, array_column(StoreReceivingBalanceOverviewService::PLATFORMS, 'slug'), true) + 1; return [ 'opening_balance' => 100.10 * $index, 'income' => 10.20 * $index, 'expense' => 1.25 * $index, ]; }); $result = $service->overview('2026-10'); $this->assertSame('2026-10', $result['month']); $this->assertSame(['start' => '2026-10-01', 'end' => '2026-10-31'], $result['period']); $this->assertCount(8, $result['platforms']); $this->assertCount(8, $seenPeriods); $this->assertSame('taobao-alipay', $seenPeriods[0][0]); $this->assertSame('xiaohongshu', $seenPeriods[7][0]); $this->assertSame(3603.60, $result['summary']['opening_balance']); $this->assertSame(367.20, $result['summary']['income']); $this->assertSame(45.00, $result['summary']['expense']); $this->assertSame(3925.80, $result['summary']['balance']); $this->assertTrue($result['summary']['complete']); $this->assertSame(8, $result['summary']['available_platform_count']); } public function test_overview_does_not_report_a_total_when_an_opening_balance_is_missing(): void { $service = $this->service(function (string $platform): array { return [ 'opening_balance' => $platform === 'vipshop-jx' ? null : 25.0, 'income' => 5.0, 'expense' => 2.0, ]; }); $result = $service->overview('2026-09'); $this->assertFalse($result['summary']['complete']); $this->assertNull($result['summary']['opening_balance']); $this->assertNull($result['summary']['income']); $this->assertNull($result['summary']['expense']); $this->assertNull($result['summary']['balance']); $this->assertSame(7, $result['summary']['available_platform_count']); $vipshopJx = array_values(array_filter( $result['platforms'], static fn (array $platform): bool => $platform['slug'] === 'vipshop-jx', ))[0]; $this->assertFalse($vipshopJx['available']); } public function test_overview_uses_an_explicit_date_range_for_all_platforms(): void { $seenPeriods = []; $service = $this->service(function (string $platform, string $start, string $end) use (&$seenPeriods): array { $seenPeriods[] = [$platform, $start, $end]; return [ 'opening_balance' => 10.0, 'income' => 2.0, 'expense' => 1.0, ]; }); $result = $service->overview('2026-10-05', '2026-10-20'); $this->assertSame(['start' => '2026-10-05', 'end' => '2026-10-20'], $result['period']); $this->assertCount(8, $seenPeriods); $this->assertSame(['taobao-alipay', '2026-10-05', '2026-10-20'], $seenPeriods[0]); $this->assertSame(['xiaohongshu', '2026-10-05', '2026-10-20'], $seenPeriods[7]); $this->assertTrue($result['summary']['complete']); } public function test_income_detail_groups_platform_store_and_bill_item_rows(): void { $service = new class extends StoreReceivingBalanceOverviewService { public function __construct() { } protected function fetchPlatformIncomeRows(string $platform, string $start, string $end): iterable { $index = array_search($platform, array_column(StoreReceivingBalanceOverviewService::PLATFORMS, 'slug'), true) + 1; yield [ 'store_name' => '店铺-' . $index, 'bill_item' => '货款收入', 'income' => 100.10 * $index, ]; yield [ 'store_name' => '店铺-' . $index, 'bill_item' => '货款收入', 'income' => 0.20 * $index, ]; yield [ 'store_name' => '店铺-' . $index, 'bill_item' => '退款', 'income' => 0, ]; } }; $result = $service->incomeDetail('2026-10'); $this->assertSame('2026-10', $result['month']); $this->assertSame(['start' => '2026-10-01', 'end' => '2026-10-31'], $result['period']); $this->assertCount(8, $result['details']); $this->assertSame([ 'platform_slug' => 'taobao-alipay', 'platform' => '淘系支付宝账户', 'store_name' => '店铺-1', 'bill_item' => '货款收入', 'amount' => 100.30, ], $result['details'][0]); $this->assertSame('xiaohongshu', $result['details'][7]['platform_slug']); $this->assertSame(3610.80, $result['summary']['income']); $this->assertTrue($result['summary']['complete']); $this->assertSame(8, $result['summary']['available_platform_count']); } public function test_expense_detail_groups_platform_store_and_bill_item_rows(): void { $service = new class extends StoreReceivingBalanceOverviewService { public function __construct() { } protected function fetchPlatformExpenseRows(string $platform, string $start, string $end): iterable { $index = array_search($platform, array_column(StoreReceivingBalanceOverviewService::PLATFORMS, 'slug'), true) + 1; yield [ 'store_name' => '店铺-' . $index, 'bill_item' => '平台扣费', 'expense' => 20.10 * $index, ]; yield [ 'store_name' => '店铺-' . $index, 'bill_item' => '平台扣费', 'expense' => 0.20 * $index, ]; yield [ 'store_name' => '店铺-' . $index, 'bill_item' => '退款', 'expense' => 0, ]; } }; $result = $service->expenseDetail('2026-10'); $this->assertSame('2026-10', $result['month']); $this->assertSame(['start' => '2026-10-01', 'end' => '2026-10-31'], $result['period']); $this->assertCount(8, $result['details']); $this->assertSame([ 'platform_slug' => 'taobao-alipay', 'platform' => '淘系支付宝账户', 'store_name' => '店铺-1', 'bill_item' => '平台扣费', 'amount' => 20.30, ], $result['details'][0]); $this->assertSame('xiaohongshu', $result['details'][7]['platform_slug']); $this->assertSame(730.80, $result['summary']['expense']); $this->assertArrayNotHasKey('income', $result['summary']); $this->assertTrue($result['summary']['complete']); $this->assertSame(8, $result['summary']['available_platform_count']); } public function test_balance_detail_uses_each_platform_closing_balance(): void { $service = $this->service(function (string $platform): array { $index = array_search($platform, array_column(StoreReceivingBalanceOverviewService::PLATFORMS, 'slug'), true) + 1; return [ 'opening_balance' => 100.10 * $index, 'income' => 10.20 * $index, 'expense' => 1.25 * $index, ]; }); $result = $service->balanceDetail('2026-10'); $this->assertSame('2026-10', $result['month']); $this->assertSame(['start' => '2026-10-01', 'end' => '2026-10-31'], $result['period']); $this->assertCount(8, $result['details']); $this->assertSame([ 'platform_slug' => 'taobao-alipay', 'platform' => '淘系支付宝账户', 'store_name' => '', 'bill_item' => '', 'amount' => 109.05, ], $result['details'][0]); $this->assertSame('xiaohongshu', $result['details'][7]['platform_slug']); $this->assertSame(872.40, $result['details'][7]['amount']); $this->assertSame(3925.80, $result['summary']['balance']); $this->assertTrue($result['summary']['complete']); $this->assertSame(8, $result['summary']['available_platform_count']); } public function test_monthly_closing_returns_july_to_october_for_all_platforms(): void { $monthOrdinal = ['2026-07' => 1, '2026-08' => 2, '2026-09' => 3, '2026-10' => 4]; $seenPeriods = []; $service = $this->service(function (string $platform, string $start, string $end) use (&$seenPeriods, $monthOrdinal): array { $seenPeriods[] = [$platform, $start, $end]; $index = array_search($platform, array_column(StoreReceivingBalanceOverviewService::PLATFORMS, 'slug'), true) + 1; return [ 'opening_balance' => 1000.00 * $index, 'income' => 10.00 * $monthOrdinal[substr($start, 0, 7)], 'expense' => 0.0, ]; }); $result = $service->monthlyClosing(); $this->assertCount(32, $seenPeriods); $this->assertSame(['2026-07', '2026-08', '2026-09', '2026-10'], array_column($result['months'], 'key')); $this->assertCount(8, $result['platforms']); $this->assertSame([ '2026-07' => 1010.00, '2026-08' => 1020.00, '2026-09' => 1030.00, '2026-10' => 1040.00, ], $result['platforms'][0]['balances']); $this->assertSame([ 'amount' => 10.0, 'rate' => 0.97, ], $result['platforms'][0]['change']); $this->assertSame('合计', $result['total']['name']); $this->assertTrue($result['summary']['complete']); $this->assertSame(8, $result['summary']['available_platform_count']); } private function service(callable $summary): StoreReceivingBalanceOverviewService { return new class($summary) extends StoreReceivingBalanceOverviewService { public function __construct(private $summaryCallback) { } protected function fetchPlatformSummary(string $platform, string $start, string $end): array { return ($this->summaryCallback)($platform, $start, $end); } }; } }