Add the period balance reconciliation page, permissions, financial closing balances, Jushuitan snapshots for Taobao Alipay, Pinduoduo and Douyin, and explicit unavailable states.
399 lines
16 KiB
PHP
399 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\EcommerceFinance;
|
|
|
|
use Modules\EcommerceFinance\Services\StoreReceivingBalanceOverviewService;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class StoreReceivingBalanceOverviewServiceTest extends TestCase
|
|
{
|
|
public function test_overview_sums_all_eight_platforms_for_the_selected_month(): void
|
|
{
|
|
$seenPeriods = [];
|
|
$service = $this->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_balance_store_detail_calculates_each_store_closing_balance(): void
|
|
{
|
|
$service = new class extends StoreReceivingBalanceOverviewService {
|
|
public function __construct()
|
|
{
|
|
}
|
|
|
|
protected function fetchPlatformStoreBalances(string $platform, string $start, string $end): array
|
|
{
|
|
$index = array_search($platform, array_column(StoreReceivingBalanceOverviewService::PLATFORMS, 'slug'), true) + 1;
|
|
|
|
return [[
|
|
'store_name' => '店铺-' . $index,
|
|
'opening_balance' => 100.10 * $index,
|
|
'income' => 10.20 * $index,
|
|
'expense' => 1.25 * $index,
|
|
]];
|
|
}
|
|
};
|
|
|
|
$result = $service->balanceStoreDetail('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' => '',
|
|
'opening_balance' => 100.10,
|
|
'income' => 10.20,
|
|
'expense' => 1.25,
|
|
'amount' => 109.05,
|
|
'available' => true,
|
|
], $result['details'][0]);
|
|
$this->assertSame(3925.80, $result['summary']['balance']);
|
|
$this->assertSame(8, $result['summary']['store_count']);
|
|
$this->assertSame(8, $result['summary']['ready_store_count']);
|
|
$this->assertTrue($result['summary']['complete']);
|
|
}
|
|
|
|
public function test_balance_store_detail_marks_missing_opening_instead_of_using_zero(): void
|
|
{
|
|
$service = new class extends StoreReceivingBalanceOverviewService {
|
|
public function __construct()
|
|
{
|
|
}
|
|
|
|
protected function fetchPlatformStoreBalances(string $platform, string $start, string $end): array
|
|
{
|
|
return [[
|
|
'store_name' => '待补期初店铺',
|
|
'opening_balance' => $platform === 'vipshop-jx' ? null : 25.0,
|
|
'income' => 5.0,
|
|
'expense' => 2.0,
|
|
]];
|
|
}
|
|
};
|
|
|
|
$result = $service->balanceStoreDetail('2026-10');
|
|
|
|
$this->assertFalse($result['summary']['complete']);
|
|
$this->assertNull($result['summary']['balance']);
|
|
$this->assertSame(7, $result['summary']['ready_store_count']);
|
|
$unavailable = array_values(array_filter(
|
|
$result['details'],
|
|
static fn (array $row): bool => $row['platform_slug'] === 'vipshop-jx',
|
|
))[0];
|
|
$this->assertNull($unavailable['opening_balance']);
|
|
$this->assertNull($unavailable['amount']);
|
|
$this->assertFalse($unavailable['available']);
|
|
}
|
|
|
|
public function test_balance_store_detail_merges_duplicate_store_rows_without_creating_zero_opening(): void
|
|
{
|
|
$service = new class extends StoreReceivingBalanceOverviewService {
|
|
public function __construct()
|
|
{
|
|
}
|
|
|
|
protected function fetchPlatformStoreBalances(string $platform, string $start, string $end): array
|
|
{
|
|
return [[
|
|
'store_name' => '重复店铺',
|
|
'opening_balance' => null,
|
|
'income' => 5.0,
|
|
'expense' => 0.0,
|
|
], [
|
|
'store_name' => '重复店铺',
|
|
'opening_balance' => null,
|
|
'income' => 0.0,
|
|
'expense' => 2.0,
|
|
]];
|
|
}
|
|
};
|
|
|
|
$result = $service->balanceStoreDetail('2026-10');
|
|
$rows = array_values(array_filter(
|
|
$result['details'],
|
|
static fn (array $row): bool => $row['store_name'] === '重复店铺',
|
|
));
|
|
|
|
$this->assertCount(1, $rows);
|
|
$this->assertNull($rows[0]['opening_balance']);
|
|
$this->assertSame(5.0, $rows[0]['income']);
|
|
$this->assertSame(2.0, $rows[0]['expense']);
|
|
$this->assertNull($rows[0]['amount']);
|
|
}
|
|
|
|
public function test_store_balances_for_platforms_filters_and_aggregates_reconciliation_sources(): void
|
|
{
|
|
$service = new class extends StoreReceivingBalanceOverviewService {
|
|
public function __construct()
|
|
{
|
|
}
|
|
|
|
protected function fetchPlatformStoreBalances(string $platform, string $start, string $end): array
|
|
{
|
|
if ($platform === 'taobao-alipay') {
|
|
return [[
|
|
'store_name' => '淘系店铺',
|
|
'opening_balance' => 100.0,
|
|
'income' => 20.0,
|
|
'expense' => 5.0,
|
|
], [
|
|
'store_name' => '淘系店铺',
|
|
'opening_balance' => 50.0,
|
|
'income' => 10.0,
|
|
'expense' => 2.0,
|
|
], [
|
|
'store_name' => '待补期初店铺',
|
|
'opening_balance' => null,
|
|
'income' => 8.0,
|
|
'expense' => 0.0,
|
|
]];
|
|
}
|
|
|
|
return [[
|
|
'store_name' => '拼多多店铺',
|
|
'opening_balance' => 300.0,
|
|
'income' => 40.0,
|
|
'expense' => 10.0,
|
|
]];
|
|
}
|
|
};
|
|
|
|
$result = $service->storeBalancesForPlatforms(
|
|
['taobao-alipay', 'pinduoduo'],
|
|
'2026-10-01',
|
|
'2026-10-31',
|
|
);
|
|
|
|
$this->assertSame(['start' => '2026-10-01', 'end' => '2026-10-31'], $result['period']);
|
|
$this->assertSame([], $result['errors']);
|
|
$this->assertCount(3, $result['rows']);
|
|
$this->assertSame('taobao-alipay', $result['rows'][0]['platform_slug']);
|
|
$this->assertSame('淘系店铺', $result['rows'][0]['store_name']);
|
|
$this->assertSame(150.0, $result['rows'][0]['opening_balance']);
|
|
$this->assertSame(30.0, $result['rows'][0]['income']);
|
|
$this->assertSame(7.0, $result['rows'][0]['expense']);
|
|
$this->assertSame(173.0, $result['rows'][0]['amount']);
|
|
$this->assertNull($result['rows'][1]['amount']);
|
|
$this->assertSame('pinduoduo', $result['rows'][2]['platform_slug']);
|
|
$this->assertSame(330.0, $result['rows'][2]['amount']);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
protected function fetchPlatformStoreBalances(string $platform, string $start, string $end): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
}
|
|
}
|