feat: add closing balance detail to account balance overview #1024
@ -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()))
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user