feat: add closing balance detail to account balance overview #1024

Merged
qiuxiang merged 1 commits from feature/qiuxiang-closing-balance-detail into main 2026-11-09 23:03:36 +08:00
7 changed files with 169 additions and 14 deletions

View File

@ -70,6 +70,26 @@ class StoreReceivingBalanceOverview extends Controller
}
}
public function balanceDetail(Request $request, StoreReceivingBalanceOverviewService $service)
{
$this->authorizeStoreReceivingBills();
$validated = $request->validate([
'month' => ['nullable', 'date_format:Y-m'],
]);
try {
return response()->json($service->balanceDetail(
(string) ($validated['month'] ?? now('Asia/Shanghai')->format('Y-m')),
));
} catch (Throwable $exception) {
report($exception);
return response()->json([
'message' => '期末余额明细暂时无法读取,请稍后重试。',
], 503);
}
}
private function authorizeStoreReceivingBills(): void
{
$section = collect(SidebarPermissions::navigationFor(user()))

View File

@ -151,6 +151,46 @@ class StoreReceivingBalanceOverviewService
return $this->flowDetail($month, 'expense');
}
/**
* 期末余额合计的平台明细。
*
* 直接复用总览的平台余额,确保明细合计与期末余额卡片口径一致。
* 明细金额统一返回元,页面再按万元展示。
*
* @return array<string,mixed>
*/
public function balanceDetail(string $month): array
{
$overview = $this->overview($month);
$details = [];
foreach ($overview['platforms'] as $platform) {
if (! $platform['available'] || ! is_numeric($platform['balance'] ?? null)) {
continue;
}
$details[] = [
'platform_slug' => $platform['slug'],
'platform' => $platform['name'],
'store_name' => '',
'bill_item' => '',
'amount' => round((float) $platform['balance'], 2),
];
}
return [
'month' => $month,
'period' => $overview['period'],
'summary' => [
'balance' => $overview['summary']['balance'],
'platform_count' => $overview['summary']['platform_count'],
'available_platform_count' => $overview['summary']['available_platform_count'],
'complete' => $overview['summary']['complete'],
],
'details' => $details,
];
}
/** @return array<string,mixed> */
private function flowDetail(string $month, string $flow): array
{

View File

@ -28,11 +28,11 @@
<div class="card-footer">
<p>{{ card.note }}</p>
<button
v-if="(card.key === 'income' || card.key === 'expense') && !loading"
v-if="(card.key === 'income' || card.key === 'expense' || card.key === 'balance') && !loading"
type="button"
class="card-detail-link"
:disabled="!summary.complete"
:title="summary.complete ? `查看${card.key === 'income' ? '收入' : '支出'}明细` : '平台数据不完整,暂不能查看明细'"
:title="summary.complete ? `查看${card.key === 'income' ? '收入' : card.key === 'expense' ? '支出' : '期末余额'}明细` : '平台数据不完整,暂不能查看明细'"
@click="openDetails(card.key)"
>查看明细 <span aria-hidden="true">→</span></button>
</div>
@ -73,7 +73,7 @@
<header class="flow-detail-head">
<div>
<strong id="flow-detail-title">{{ detailTitle }}</strong>
<p>{{ monthLabel }} · 按平台、店铺和账单项目汇总</p>
<p>{{ monthLabel }} · {{ detailDescription }}</p>
</div>
<div class="flow-detail-total">
<span>当前筛选合计(万元)</span>
@ -83,7 +83,7 @@
</header>
<div class="flow-detail-body">
<div class="flow-detail-filters">
<div class="flow-detail-filters" :class="{ 'is-platform-only': detailFlow === 'balance' }">
<label>
<span>平台</span>
<select v-model="detailPlatform" aria-label="筛选平台" @change="normalizeDetailFilters">
@ -91,14 +91,14 @@
<option v-for="platform in detailPlatformOptions" :key="platform.slug" :value="platform.slug">{{ platform.name }}</option>
</select>
</label>
<label>
<label v-if="detailFlow !== 'balance'">
<span>店铺</span>
<select v-model="detailStore" aria-label="筛选店铺" @change="normalizeDetailFilters">
<option value="">全部店铺</option>
<option v-for="store in detailStoreOptions" :key="store" :value="store">{{ store }}</option>
</select>
</label>
<label>
<label v-if="detailFlow !== 'balance'">
<span>账单项目</span>
<select v-model="detailBillItem" aria-label="筛选账单项目">
<option value="">全部账单项目</option>
@ -127,7 +127,7 @@
<div v-else class="flow-detail-table-wrap">
<table v-if="detailTab === 'platform'" class="flow-detail-table">
<thead>
<tr><th scope="col">平台</th><th scope="col" class="is-number">总金额(万元)</th></tr>
<tr><th scope="col">平台</th><th scope="col" class="is-number">{{ detailPlatformAmountHeader }}</th></tr>
</thead>
<tbody>
<tr v-for="row in detailRows" :key="row.key">
@ -215,11 +215,6 @@ export default {
detailDetails: [],
detailRequestSerial: 0,
detailTab: 'platform',
detailTabs: [
{ key: 'platform', label: '平台' },
{ key: 'store', label: '店铺' },
{ key: 'bill_item', label: '账单项目' },
],
detailPlatform: '',
detailStore: '',
detailBillItem: '',
@ -281,11 +276,31 @@ export default {
];
},
detailFlowLabel() {
if (this.detailFlow === 'balance') return '期末余额';
return this.detailFlow === 'expense' ? '支出' : '收入';
},
detailTitle() {
return `${this.detailFlowLabel}合计 · 明细汇总`;
},
detailDescription() {
return this.detailFlow === 'balance'
? '按平台汇总期末余额'
: '按平台、店铺和账单项目汇总';
},
detailTabs() {
if (this.detailFlow === 'balance') {
return [{ key: 'platform', label: '平台' }];
}
return [
{ key: 'platform', label: '平台' },
{ key: 'store', label: '店铺' },
{ key: 'bill_item', label: '账单项目' },
];
},
detailPlatformAmountHeader() {
return this.detailFlow === 'balance' ? '期末余额(万元)' : '总金额(万元)';
},
detailPlatformOptions() {
return this.platforms.map(platform => ({
slug: platform.slug,
@ -425,7 +440,7 @@ export default {
},
openDetails(flow = 'income') {
const requestId = ++this.detailRequestSerial;
this.detailFlow = flow === 'expense' ? 'expense' : 'income';
this.detailFlow = ['income', 'expense', 'balance'].includes(flow) ? flow : 'income';
this.detailOpen = true;
this.detailTab = 'platform';
this.detailPlatform = '';
@ -447,7 +462,11 @@ export default {
this.detailError = '';
try {
const endpoint = this.detailFlow === 'expense' ? 'expense-detail' : 'income-detail';
const endpoint = {
income: 'income-detail',
expense: 'expense-detail',
balance: 'balance-detail',
}[this.detailFlow] || 'income-detail';
const response = await window.axios.get(`${this.apiBase}/${endpoint}`, {
params: { month: this.month },
});
@ -847,6 +866,9 @@ export default {
border-radius: .55rem;
background: #fbf9f4;
}
.flow-detail-filters.is-platform-only {
grid-template-columns: minmax(0, 18rem);
}
.flow-detail-filters label {
display: flex;
min-width: 0;

View File

@ -43,6 +43,8 @@ Route::get('ecommerce-finance/account-bills/overview/income-detail', [StoreRecei
->name('ecommerce-finance.account-bills.overview.income-detail');
Route::get('ecommerce-finance/account-bills/overview/expense-detail', [StoreReceivingBalanceOverview::class, 'expenseDetail'])
->name('ecommerce-finance.account-bills.overview.expense-detail');
Route::get('ecommerce-finance/account-bills/overview/balance-detail', [StoreReceivingBalanceOverview::class, 'balanceDetail'])
->name('ecommerce-finance.account-bills.overview.balance-detail');
Route::get('ecommerce-finance/alipay-bills', [AlipayBills::class, 'index'])
->name('ecommerce-finance.alipay-bills.index');
Route::get('ecommerce-finance/alipay-bills/export', [AlipayBills::class, 'export'])

View File

@ -131,4 +131,41 @@ class StoreReceivingBalanceOverviewEndpointTest extends FeatureTestCase
->assertJsonPath('details.0.platform_slug', 'jd-wallet')
->assertJsonPath('details.0.bill_item', '平台扣费');
}
public function test_balance_detail_endpoint_returns_platform_closing_balances(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
$payload = [
'month' => '2026-10',
'period' => ['start' => '2026-10-01', 'end' => '2026-10-31'],
'summary' => [
'balance' => 1049.07,
'platform_count' => 8,
'available_platform_count' => 8,
'complete' => true,
],
'details' => [[
'platform_slug' => 'taobao-alipay',
'platform' => '淘系支付宝账户',
'store_name' => '',
'bill_item' => '',
'amount' => 218.88,
]],
];
$service = Mockery::mock(StoreReceivingBalanceOverviewService::class);
$service->shouldReceive('balanceDetail')->once()->with('2026-10')->andReturn($payload);
$this->app->instance(StoreReceivingBalanceOverviewService::class, $service);
$this->loginAs()
->getJson(route('ecommerce-finance.account-bills.overview.balance-detail', [
'month' => '2026-10',
]))
->assertOk()
->assertJsonPath('summary.balance', 1049.07)
->assertJsonPath('details.0.platform_slug', 'taobao-alipay')
->assertJsonPath('details.0.amount', 218.88);
}
}

View File

@ -157,6 +157,37 @@ class StoreReceivingBalanceOverviewServiceTest extends TestCase
$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']);
}
private function service(callable $summary): StoreReceivingBalanceOverviewService
{
return new class($summary) extends StoreReceivingBalanceOverviewService {

View File

@ -40,6 +40,9 @@ class StoreReceivingBalanceOverviewTest extends TestCase
$this->assertStringContainsString("{ key: 'bill_item', label: '账单项目' }", $source);
$this->assertStringContainsString('income-detail', $source);
$this->assertStringContainsString('expense-detail', $source);
$this->assertStringContainsString('balance-detail', $source);
$this->assertStringContainsString("detailFlow === 'balance'", $source);
$this->assertStringContainsString('期末余额(万元)', $source);
$this->assertStringContainsString('当前筛选合计(万元)', $source);
}