feat: add expense detail summary to account balance overview (#1023)

This commit is contained in:
qiuxiang 2026-11-09 22:27:19 +08:00
parent 449c010c13
commit f99a9eb449
7 changed files with 362 additions and 191 deletions

View File

@ -50,6 +50,26 @@ class StoreReceivingBalanceOverview extends Controller
}
}
public function expenseDetail(Request $request, StoreReceivingBalanceOverviewService $service)
{
$this->authorizeStoreReceivingBills();
$validated = $request->validate([
'month' => ['nullable', 'date_format:Y-m'],
]);
try {
return response()->json($service->expenseDetail(
(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

@ -134,24 +134,51 @@ class StoreReceivingBalanceOverviewService
* @return array<string,mixed>
*/
public function incomeDetail(string $month): array
{
return $this->flowDetail($month, 'income');
}
/**
* 支出合计的平台、店铺、账单项目明细。
*
* 支出明细沿用各平台账单页的同一数据源和账期口径,只汇总支出行;
* 明细金额统一返回元,页面再按万元展示。
*
* @return array<string,mixed>
*/
public function expenseDetail(string $month): array
{
return $this->flowDetail($month, 'expense');
}
/** @return array<string,mixed> */
private function flowDetail(string $month, string $flow): array
{
[$periodStart, $periodEnd] = $this->periodForMonth($month);
$platformOrder = [];
$details = [];
$totalIncome = 0.0;
$totalAmount = 0.0;
foreach (self::PLATFORMS as $index => $definition) {
$platformOrder[$definition['slug']] = $index;
$buckets = [];
try {
foreach ($this->fetchPlatformIncomeRows(
$definition['slug'],
$periodStart,
$periodEnd,
) as $row) {
$income = round((float) ($row['income'] ?? 0), 2);
if ($income <= 0) {
$rows = $flow === 'income'
? $this->fetchPlatformIncomeRows(
$definition['slug'],
$periodStart,
$periodEnd,
)
: $this->fetchPlatformExpenseRows(
$definition['slug'],
$periodStart,
$periodEnd,
);
foreach ($rows as $row) {
$amount = round((float) ($row[$flow] ?? 0), 2);
if ($amount <= 0) {
continue;
}
@ -161,13 +188,14 @@ class StoreReceivingBalanceOverviewService
$billItem = $billItem !== '' ? $billItem : '未标注账单项目';
$buckets[$storeName][$billItem] = round(
(float) ($buckets[$storeName][$billItem] ?? 0) + $income,
(float) ($buckets[$storeName][$billItem] ?? 0) + $amount,
2,
);
$totalIncome += $income;
$totalAmount += $amount;
}
} catch (Throwable $exception) {
throw new RuntimeException('收入明细暂时无法读取,请稍后重试。', 0, $exception);
$label = $flow === 'income' ? '收入' : '支出';
throw new RuntimeException($label . '明细暂时无法读取,请稍后重试。', 0, $exception);
}
foreach ($buckets as $storeName => $items) {
@ -204,7 +232,7 @@ class StoreReceivingBalanceOverviewService
'end' => $periodEnd,
],
'summary' => [
'income' => round($totalIncome, 2),
$flow => round($totalAmount, 2),
'platform_count' => count(self::PLATFORMS),
'available_platform_count' => count(self::PLATFORMS),
'complete' => true,
@ -234,11 +262,32 @@ class StoreReceivingBalanceOverviewService
* @return iterable<int,array{store_name?:mixed,bill_item?:mixed,income?:mixed}>
*/
protected function fetchPlatformIncomeRows(string $platform, string $start, string $end): iterable
{
return $this->fetchPlatformRows($platform, $start, $end, 'income');
}
/**
* 返回某平台所选月份的支出行。
*
* @return iterable<int,array<string,mixed>>
*/
protected function fetchPlatformExpenseRows(string $platform, string $start, string $end): iterable
{
return $this->fetchPlatformRows($platform, $start, $end, 'expense');
}
/** @return iterable<int,array<string,mixed>> */
private function fetchPlatformRows(
string $platform,
string $start,
string $end,
string $flow,
): iterable
{
$filters = [
'start' => $start,
'end' => $end,
'business_type' => '收入',
'business_type' => $flow === 'income' ? '收入' : '支出',
];
return match ($platform) {
@ -252,27 +301,28 @@ class StoreReceivingBalanceOverviewService
->exportRows($filters, 'occurred_at', 'asc'),
'douyin' => app(StoreReceivingDouyinBillMirrorRepository::class)
->exportRows($filters, 'occurred_at', 'asc'),
'vipshop', 'vipshop-jx', 'xiaohongshu' => $this->accountBillIncomeRows(
'vipshop', 'vipshop-jx', 'xiaohongshu' => $this->accountBillRows(
$platform,
$start,
$end,
$flow,
),
default => throw new RuntimeException('unsupported_balance_overview_platform'),
};
}
/**
* 本系统落库账户按对应账单页相同的账期规则读取收入:
* 本系统落库账户按对应账单页相同的账期规则读取收入或支出:
* 唯品会按 payload.settle_period,小红书等按 occurred_at。
*
* @return iterable<int,array{store_name:string,bill_item:string,income:float}>
* @return iterable<int,array<string,mixed>>
*/
private function accountBillIncomeRows(string $platform, string $start, string $end): iterable
private function accountBillRows(string $platform, string $start, string $end, string $flow): iterable
{
$query = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', $platform)
->where('income', '>', 0);
->where($flow, '>', 0);
if (in_array($platform, ['vipshop', 'vipshop-jx'], true)) {
$query->whereRaw(
@ -291,6 +341,7 @@ class StoreReceivingBalanceOverviewService
? (string) data_get($bill->payload ?? [], 'second_remark', '')
: (string) ($bill->bill_item ?? ''),
'income' => (float) $bill->income,
'expense' => (float) $bill->expense,
];
}
}

View File

@ -28,12 +28,12 @@
<div class="card-footer">
<p>{{ card.note }}</p>
<button
v-if="card.key === 'income' && !loading"
v-if="(card.key === 'income' || card.key === 'expense') && !loading"
type="button"
class="card-detail-link"
:disabled="!summary.complete"
:title="summary.complete ? '查看收入明细' : '平台数据不完整,暂不能查看明细'"
@click="openIncomeDetails"
:title="summary.complete ? `查看${card.key === 'income' ? '收入' : '支出'}明细` : '平台数据不完整,暂不能查看明细'"
@click="openDetails(card.key)"
>查看明细 <span aria-hidden="true">→</span></button>
</div>
</article>
@ -61,117 +61,117 @@
</section>
<div
v-if="incomeDetailOpen"
class="income-detail-modal"
v-if="detailOpen"
class="flow-detail-modal"
role="dialog"
aria-modal="true"
aria-labelledby="income-detail-title"
@click.self="closeIncomeDetails"
@keydown.esc="closeIncomeDetails"
aria-labelledby="flow-detail-title"
@click.self="closeDetails"
@keydown.esc="closeDetails"
>
<section ref="incomeDetailDialog" class="income-detail-dialog" tabindex="-1">
<header class="income-detail-head">
<section ref="detailDialog" class="flow-detail-dialog" tabindex="-1">
<header class="flow-detail-head">
<div>
<strong id="income-detail-title">收入合计 · 明细汇总</strong>
<strong id="flow-detail-title">{{ detailTitle }}</strong>
<p>{{ monthLabel }} · 按平台、店铺和账单项目汇总</p>
</div>
<div class="income-detail-total">
<div class="flow-detail-total">
<span>当前筛选合计(万元)</span>
<strong>{{ incomeDetailLoading ? '加载中…' : moneyWan(incomeDetailFilteredTotal) }}</strong>
<strong>{{ detailLoading ? '加载中…' : moneyWan(detailFilteredTotal) }}</strong>
</div>
<button type="button" class="income-detail-close" aria-label="关闭收入明细" @click="closeIncomeDetails">✕</button>
<button type="button" class="flow-detail-close" :aria-label="`关闭${detailFlowLabel}明细`" @click="closeDetails">✕</button>
</header>
<div class="income-detail-body">
<div class="income-detail-filters">
<div class="flow-detail-body">
<div class="flow-detail-filters">
<label>
<span>平台</span>
<select v-model="incomeDetailPlatform" aria-label="筛选平台" @change="normalizeIncomeDetailFilters">
<select v-model="detailPlatform" aria-label="筛选平台" @change="normalizeDetailFilters">
<option value="">全部平台</option>
<option v-for="platform in incomeDetailPlatformOptions" :key="platform.slug" :value="platform.slug">{{ platform.name }}</option>
<option v-for="platform in detailPlatformOptions" :key="platform.slug" :value="platform.slug">{{ platform.name }}</option>
</select>
</label>
<label>
<span>店铺</span>
<select v-model="incomeDetailStore" aria-label="筛选店铺" @change="normalizeIncomeDetailFilters">
<select v-model="detailStore" aria-label="筛选店铺" @change="normalizeDetailFilters">
<option value="">全部店铺</option>
<option v-for="store in incomeDetailStoreOptions" :key="store" :value="store">{{ store }}</option>
<option v-for="store in detailStoreOptions" :key="store" :value="store">{{ store }}</option>
</select>
</label>
<label>
<span>账单项目</span>
<select v-model="incomeDetailBillItem" aria-label="筛选账单项目">
<select v-model="detailBillItem" aria-label="筛选账单项目">
<option value="">全部账单项目</option>
<option v-for="item in incomeDetailBillItemOptions" :key="item" :value="item">{{ item }}</option>
<option v-for="item in detailBillItemOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
</div>
<div class="income-detail-tabs" role="tablist" aria-label="收入明细汇总方式">
<div class="flow-detail-tabs" role="tablist" :aria-label="`${detailFlowLabel}明细汇总方式`">
<button
v-for="tab in incomeDetailTabs"
v-for="tab in detailTabs"
:key="tab.key"
type="button"
role="tab"
:aria-selected="incomeDetailTab === tab.key ? 'true' : 'false'"
:class="{ 'is-active': incomeDetailTab === tab.key }"
@click="incomeDetailTab = tab.key"
:aria-selected="detailTab === tab.key ? 'true' : 'false'"
:class="{ 'is-active': detailTab === tab.key }"
@click="detailTab = tab.key"
>{{ tab.label }}</button>
</div>
<div v-if="incomeDetailError" class="income-detail-state is-error" role="alert">
<span>{{ incomeDetailError }}</span>
<button type="button" @click="fetchIncomeDetails">重新加载</button>
<div v-if="detailError" class="flow-detail-state is-error" role="alert">
<span>{{ detailError }}</span>
<button type="button" @click="fetchDetails">重新加载</button>
</div>
<div v-else-if="incomeDetailLoading" class="income-detail-state" role="status">正在汇总各平台收入明细…</div>
<div v-else class="income-detail-table-wrap">
<table v-if="incomeDetailTab === 'platform'" class="income-detail-table">
<div v-else-if="detailLoading" class="flow-detail-state" role="status">正在汇总各平台{{ detailFlowLabel }}明细…</div>
<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>
</thead>
<tbody>
<tr v-for="row in incomeDetailRows" :key="row.key">
<tr v-for="row in detailRows" :key="row.key">
<td>{{ row.platform }}</td>
<td class="is-number">{{ moneyWan(row.amount) }}</td>
</tr>
<tr v-if="!incomeDetailRows.length"><td colspan="2" class="income-detail-empty">当前筛选范围内暂无收入明细</td></tr>
<tr v-if="!detailRows.length"><td colspan="2" class="flow-detail-empty">当前筛选范围内暂无{{ detailFlowLabel }}明细</td></tr>
</tbody>
</table>
<table v-else-if="incomeDetailTab === 'store'" class="income-detail-table">
<table v-else-if="detailTab === 'store'" class="flow-detail-table">
<thead>
<tr><th scope="col">平台</th><th scope="col">店铺</th><th scope="col" class="is-number">收入金额(万元)</th></tr>
<tr><th scope="col">平台</th><th scope="col">店铺</th><th scope="col" class="is-number">{{ detailFlowLabel }}金额(万元)</th></tr>
</thead>
<tbody>
<tr v-for="row in incomeDetailRows" :key="row.key">
<tr v-for="row in detailRows" :key="row.key">
<td>{{ row.platform }}</td>
<td>{{ row.store_name }}</td>
<td class="is-number">{{ moneyWan(row.amount) }}</td>
</tr>
<tr v-if="!incomeDetailRows.length"><td colspan="3" class="income-detail-empty">当前筛选范围内暂无收入明细</td></tr>
<tr v-if="!detailRows.length"><td colspan="3" class="flow-detail-empty">当前筛选范围内暂无{{ detailFlowLabel }}明细</td></tr>
</tbody>
</table>
<table v-else class="income-detail-table">
<table v-else class="flow-detail-table">
<thead>
<tr><th scope="col">平台</th><th scope="col">账单项目</th><th scope="col" class="is-number">收入金额(万元)</th></tr>
<tr><th scope="col">平台</th><th scope="col">账单项目</th><th scope="col" class="is-number">{{ detailFlowLabel }}金额(万元)</th></tr>
</thead>
<tbody>
<tr v-for="row in incomeDetailRows" :key="row.key">
<tr v-for="row in detailRows" :key="row.key">
<td>{{ row.platform }}</td>
<td>{{ row.bill_item }}</td>
<td class="is-number">{{ moneyWan(row.amount) }}</td>
</tr>
<tr v-if="!incomeDetailRows.length"><td colspan="3" class="income-detail-empty">当前筛选范围内暂无收入明细</td></tr>
<tr v-if="!detailRows.length"><td colspan="3" class="flow-detail-empty">当前筛选范围内暂无{{ detailFlowLabel }}明细</td></tr>
</tbody>
</table>
</div>
</div>
<footer class="income-detail-foot">
<span v-if="!incomeDetailLoading && !incomeDetailError">
共 {{ incomeDetailRows.length }} 条{{ currentIncomeDetailTabLabel }}汇总
<footer class="flow-detail-foot">
<span v-if="!detailLoading && !detailError">
共 {{ detailRows.length }} 条{{ currentDetailTabLabel }}汇总
</span>
<span v-else>&nbsp;</span>
<button type="button" @click="closeIncomeDetails">关闭</button>
<button type="button" @click="closeDetails">关闭</button>
</footer>
</section>
</div>
@ -208,21 +208,22 @@ export default {
summary: emptySummary(),
platforms: [],
requestSerial: 0,
incomeDetailOpen: false,
incomeDetailLoading: false,
incomeDetailError: '',
incomeDetailDetails: [],
incomeDetailRequestSerial: 0,
incomeDetailTab: 'platform',
incomeDetailTabs: [
detailOpen: false,
detailFlow: 'income',
detailLoading: false,
detailError: '',
detailDetails: [],
detailRequestSerial: 0,
detailTab: 'platform',
detailTabs: [
{ key: 'platform', label: '平台' },
{ key: 'store', label: '店铺' },
{ key: 'bill_item', label: '账单项目' },
],
incomeDetailPlatform: '',
incomeDetailStore: '',
incomeDetailBillItem: '',
bodyOverflowBeforeIncomeDetail: '',
detailPlatform: '',
detailStore: '',
detailBillItem: '',
bodyOverflowBeforeDetail: '',
};
},
computed: {
@ -279,54 +280,60 @@ export default {
},
];
},
incomeDetailPlatformOptions() {
detailFlowLabel() {
return this.detailFlow === 'expense' ? '支出' : '收入';
},
detailTitle() {
return `${this.detailFlowLabel}合计 · 明细汇总`;
},
detailPlatformOptions() {
return this.platforms.map(platform => ({
slug: platform.slug,
name: platform.name,
}));
},
incomeDetailStoreOptions() {
detailStoreOptions() {
return [...new Set(
this.incomeDetailDetails
.filter(row => !this.incomeDetailPlatform || row.platform_slug === this.incomeDetailPlatform)
this.detailDetails
.filter(row => !this.detailPlatform || row.platform_slug === this.detailPlatform)
.map(row => row.store_name)
.filter(Boolean),
)].sort((left, right) => left.localeCompare(right, 'zh-CN'));
},
incomeDetailBillItemOptions() {
detailBillItemOptions() {
return [...new Set(
this.incomeDetailDetails
.filter(row => !this.incomeDetailPlatform || row.platform_slug === this.incomeDetailPlatform)
.filter(row => !this.incomeDetailStore || row.store_name === this.incomeDetailStore)
this.detailDetails
.filter(row => !this.detailPlatform || row.platform_slug === this.detailPlatform)
.filter(row => !this.detailStore || row.store_name === this.detailStore)
.map(row => row.bill_item)
.filter(Boolean),
)].sort((left, right) => left.localeCompare(right, 'zh-CN'));
},
incomeDetailFilteredDetails() {
return this.incomeDetailDetails.filter((row) => (
(!this.incomeDetailPlatform || row.platform_slug === this.incomeDetailPlatform)
&& (!this.incomeDetailStore || row.store_name === this.incomeDetailStore)
&& (!this.incomeDetailBillItem || row.bill_item === this.incomeDetailBillItem)
detailFilteredDetails() {
return this.detailDetails.filter((row) => (
(!this.detailPlatform || row.platform_slug === this.detailPlatform)
&& (!this.detailStore || row.store_name === this.detailStore)
&& (!this.detailBillItem || row.bill_item === this.detailBillItem)
));
},
incomeDetailFilteredTotal() {
if (this.incomeDetailLoading || this.incomeDetailError) return null;
detailFilteredTotal() {
if (this.detailLoading || this.detailError) return null;
return this.incomeDetailFilteredDetails.reduce(
return this.detailFilteredDetails.reduce(
(total, row) => total + Number(row.amount || 0),
0,
);
},
incomeDetailRows() {
detailRows() {
const grouped = new Map();
const platformOrder = new Map(
this.platforms.map((platform, index) => [platform.slug, index]),
);
if (this.incomeDetailTab === 'platform') {
if (this.detailTab === 'platform') {
this.platforms.forEach((platform) => {
if (this.incomeDetailPlatform && platform.slug !== this.incomeDetailPlatform) return;
const key = [this.incomeDetailTab, platform.slug, platform.name].join('|');
if (this.detailPlatform && platform.slug !== this.detailPlatform) return;
const key = [this.detailTab, platform.slug, platform.name].join('|');
grouped.set(key, {
key,
platform_slug: platform.slug,
@ -338,11 +345,11 @@ export default {
});
}
this.incomeDetailFilteredDetails.forEach((row) => {
this.detailFilteredDetails.forEach((row) => {
let name = row.platform;
if (this.incomeDetailTab === 'store') name = row.store_name;
if (this.incomeDetailTab === 'bill_item') name = row.bill_item;
const key = [this.incomeDetailTab, row.platform_slug, name].join('|');
if (this.detailTab === 'store') name = row.store_name;
if (this.detailTab === 'bill_item') name = row.bill_item;
const key = [this.detailTab, row.platform_slug, name].join('|');
const current = grouped.get(key) || {
key,
platform_slug: row.platform_slug,
@ -358,7 +365,7 @@ export default {
return Array.from(grouped.values())
.map(row => ({ ...row, amount: Math.round(row.amount * 100) / 100 }))
.sort((left, right) => {
if (this.incomeDetailTab === 'platform') {
if (this.detailTab === 'platform') {
return (platformOrder.get(left.platform_slug) ?? 99)
- (platformOrder.get(right.platform_slug) ?? 99);
}
@ -372,8 +379,8 @@ export default {
);
});
},
currentIncomeDetailTabLabel() {
const tab = this.incomeDetailTabs.find(item => item.key === this.incomeDetailTab);
currentDetailTabLabel() {
const tab = this.detailTabs.find(item => item.key === this.detailTab);
return tab ? tab.label : '';
},
},
@ -381,7 +388,7 @@ export default {
this.fetchOverview();
},
beforeDestroy() {
if (this.incomeDetailOpen) this.unlockIncomeDetailScroll();
if (this.detailOpen) this.unlockDetailScroll();
},
methods: {
async fetchOverview() {
@ -412,73 +419,75 @@ export default {
changeMonth(offset) {
const [year, month] = this.month.split('-').map(Number);
const next = new Date(year, month - 1 + offset, 1);
if (this.incomeDetailOpen) this.closeIncomeDetails();
if (this.detailOpen) this.closeDetails();
this.month = `${next.getFullYear()}-${pad(next.getMonth() + 1)}`;
this.fetchOverview();
},
openIncomeDetails() {
const requestId = ++this.incomeDetailRequestSerial;
this.incomeDetailOpen = true;
this.incomeDetailTab = 'platform';
this.incomeDetailPlatform = '';
this.incomeDetailStore = '';
this.incomeDetailBillItem = '';
this.incomeDetailDetails = [];
this.incomeDetailError = '';
this.lockIncomeDetailScroll();
openDetails(flow = 'income') {
const requestId = ++this.detailRequestSerial;
this.detailFlow = flow === 'expense' ? 'expense' : 'income';
this.detailOpen = true;
this.detailTab = 'platform';
this.detailPlatform = '';
this.detailStore = '';
this.detailBillItem = '';
this.detailDetails = [];
this.detailError = '';
this.lockDetailScroll();
this.$nextTick(() => {
if (requestId === this.incomeDetailRequestSerial && this.$refs.incomeDetailDialog) {
this.$refs.incomeDetailDialog.focus();
if (requestId === this.detailRequestSerial && this.$refs.detailDialog) {
this.$refs.detailDialog.focus();
}
});
this.fetchIncomeDetails();
this.fetchDetails();
},
async fetchIncomeDetails() {
const requestId = ++this.incomeDetailRequestSerial;
this.incomeDetailLoading = true;
this.incomeDetailError = '';
async fetchDetails() {
const requestId = ++this.detailRequestSerial;
this.detailLoading = true;
this.detailError = '';
try {
const response = await window.axios.get(`${this.apiBase}/income-detail`, {
const endpoint = this.detailFlow === 'expense' ? 'expense-detail' : 'income-detail';
const response = await window.axios.get(`${this.apiBase}/${endpoint}`, {
params: { month: this.month },
});
if (requestId !== this.incomeDetailRequestSerial || !this.incomeDetailOpen) return;
if (requestId !== this.detailRequestSerial || !this.detailOpen) return;
const body = response.data || {};
this.incomeDetailDetails = Array.isArray(body.details) ? body.details : [];
this.normalizeIncomeDetailFilters();
this.detailDetails = Array.isArray(body.details) ? body.details : [];
this.normalizeDetailFilters();
} catch (error) {
if (requestId !== this.incomeDetailRequestSerial || !this.incomeDetailOpen) return;
this.incomeDetailDetails = [];
this.incomeDetailError = (error.response && error.response.data && error.response.data.message)
|| '收入明细暂时无法读取,请稍后重试。';
if (requestId !== this.detailRequestSerial || !this.detailOpen) return;
this.detailDetails = [];
this.detailError = (error.response && error.response.data && error.response.data.message)
|| `${this.detailFlowLabel}明细暂时无法读取,请稍后重试。`;
} finally {
if (requestId === this.incomeDetailRequestSerial) this.incomeDetailLoading = false;
if (requestId === this.detailRequestSerial) this.detailLoading = false;
}
},
normalizeIncomeDetailFilters() {
if (this.incomeDetailPlatform
&& !this.incomeDetailPlatformOptions.some(item => item.slug === this.incomeDetailPlatform)) {
this.incomeDetailPlatform = '';
normalizeDetailFilters() {
if (this.detailPlatform
&& !this.detailPlatformOptions.some(item => item.slug === this.detailPlatform)) {
this.detailPlatform = '';
}
if (this.incomeDetailStore && !this.incomeDetailStoreOptions.includes(this.incomeDetailStore)) {
this.incomeDetailStore = '';
if (this.detailStore && !this.detailStoreOptions.includes(this.detailStore)) {
this.detailStore = '';
}
if (this.incomeDetailBillItem && !this.incomeDetailBillItemOptions.includes(this.incomeDetailBillItem)) {
this.incomeDetailBillItem = '';
if (this.detailBillItem && !this.detailBillItemOptions.includes(this.detailBillItem)) {
this.detailBillItem = '';
}
},
closeIncomeDetails() {
++this.incomeDetailRequestSerial;
this.incomeDetailOpen = false;
this.incomeDetailLoading = false;
this.unlockIncomeDetailScroll();
closeDetails() {
++this.detailRequestSerial;
this.detailOpen = false;
this.detailLoading = false;
this.unlockDetailScroll();
},
lockIncomeDetailScroll() {
this.bodyOverflowBeforeIncomeDetail = document.body.style.overflow;
lockDetailScroll() {
this.bodyOverflowBeforeDetail = document.body.style.overflow;
document.body.style.overflow = 'hidden';
},
unlockIncomeDetailScroll() {
document.body.style.overflow = this.bodyOverflowBeforeIncomeDetail;
unlockDetailScroll() {
document.body.style.overflow = this.bodyOverflowBeforeDetail;
},
money(value) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—';
@ -748,7 +757,7 @@ export default {
border-radius: 50%;
background: currentColor;
}
.income-detail-modal {
.flow-detail-modal {
position: fixed;
z-index: 10000;
inset: 0;
@ -759,7 +768,7 @@ export default {
background: rgba(26, 23, 18, .48);
backdrop-filter: blur(2px);
}
.income-detail-dialog {
.flow-detail-dialog {
display: flex;
width: min(68rem, 100%);
max-height: calc(100vh - 3rem);
@ -771,7 +780,7 @@ export default {
box-shadow: 0 4px 8px rgba(23, 23, 23, .10);
outline: none;
}
.income-detail-head {
.flow-detail-head {
display: flex;
align-items: flex-start;
gap: 1rem;
@ -779,31 +788,31 @@ export default {
border-bottom: 1px solid #eee7da;
background: linear-gradient(135deg, #fffdf8, #faf5ea);
}
.income-detail-head strong {
.flow-detail-head strong {
display: block;
font-size: .95rem;
}
.income-detail-head p {
.flow-detail-head p {
margin: .25rem 0 0;
color: var(--overview-muted);
font-size: .6875rem;
}
.income-detail-total {
.flow-detail-total {
margin-left: auto;
text-align: right;
}
.income-detail-total span {
.flow-detail-total span {
display: block;
color: var(--overview-muted);
font-size: .6875rem;
}
.income-detail-total strong {
.flow-detail-total strong {
margin-top: .2rem;
color: var(--overview-ink);
font-size: 1rem;
font-variant-numeric: tabular-nums;
}
.income-detail-close {
.flow-detail-close {
display: inline-flex;
width: 1.8rem;
height: 1.8rem;
@ -817,18 +826,18 @@ export default {
font: 500 .9rem/1 inherit;
cursor: pointer;
}
.income-detail-close:hover,
.income-detail-close:focus-visible {
.flow-detail-close:hover,
.flow-detail-close:focus-visible {
border-color: var(--overview-gold);
color: var(--overview-gold);
outline: none;
}
.income-detail-body {
.flow-detail-body {
min-height: 0;
padding: 1rem 1.15rem;
overflow: auto;
}
.income-detail-filters {
.flow-detail-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .75rem;
@ -838,7 +847,7 @@ export default {
border-radius: .55rem;
background: #fbf9f4;
}
.income-detail-filters label {
.flow-detail-filters label {
display: flex;
min-width: 0;
flex-direction: column;
@ -846,7 +855,7 @@ export default {
color: var(--overview-muted);
font-size: .6875rem;
}
.income-detail-filters select {
.flow-detail-filters select {
width: 100%;
height: 2rem;
min-width: 0;
@ -857,18 +866,18 @@ export default {
color: var(--overview-ink);
font: inherit;
}
.income-detail-filters select:focus {
.flow-detail-filters select:focus {
border-color: var(--overview-gold);
box-shadow: 0 0 0 3px rgba(138, 104, 47, .16);
outline: none;
}
.income-detail-tabs {
.flow-detail-tabs {
display: flex;
gap: 1.25rem;
margin-bottom: .75rem;
border-bottom: 1px solid #ded6c8;
}
.income-detail-tabs button {
.flow-detail-tabs button {
position: relative;
padding: .55rem 0;
border: 0;
@ -877,10 +886,10 @@ export default {
font: 600 .75rem/1.4 inherit;
cursor: pointer;
}
.income-detail-tabs button.is-active {
.flow-detail-tabs button.is-active {
color: var(--overview-gold);
}
.income-detail-tabs button.is-active::after {
.flow-detail-tabs button.is-active::after {
position: absolute;
right: 0;
bottom: -1px;
@ -889,12 +898,12 @@ export default {
background: #b9954f;
content: "";
}
.income-detail-tabs button:focus-visible {
.flow-detail-tabs button:focus-visible {
border-radius: .25rem;
box-shadow: 0 0 0 3px rgba(138, 104, 47, .18);
outline: none;
}
.income-detail-state {
.flow-detail-state {
display: flex;
min-height: 15rem;
align-items: center;
@ -906,12 +915,12 @@ export default {
color: var(--overview-muted);
font-size: .75rem;
}
.income-detail-state.is-error {
.flow-detail-state.is-error {
border-color: #d8aaa0;
background: #fff6f3;
color: #9b3c2d;
}
.income-detail-state button {
.flow-detail-state button {
padding: .3rem .6rem;
border: 1px solid currentColor;
border-radius: .35rem;
@ -920,20 +929,20 @@ export default {
font: inherit;
cursor: pointer;
}
.income-detail-table-wrap {
.flow-detail-table-wrap {
max-height: min(58vh, 32rem);
overflow: auto;
border: 1px solid #ded6c8;
border-radius: .55rem;
}
.income-detail-table {
.flow-detail-table {
width: 100%;
min-width: 34rem;
border-collapse: separate;
border-spacing: 0;
font-size: .75rem;
}
.income-detail-table th {
.flow-detail-table th {
position: sticky;
z-index: 1;
top: 0;
@ -945,25 +954,25 @@ export default {
text-align: left;
white-space: nowrap;
}
.income-detail-table td {
.flow-detail-table td {
padding: .62rem .8rem;
border-bottom: 1px solid #eee8dd;
background: rgba(255, 255, 255, .82);
}
.income-detail-table tbody tr:hover td {
.flow-detail-table tbody tr:hover td {
background: #fffaf0;
}
.income-detail-table .is-number {
.flow-detail-table .is-number {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.income-detail-empty {
.flow-detail-empty {
padding: 4rem 1rem !important;
color: var(--overview-muted);
text-align: center !important;
}
.income-detail-foot {
.flow-detail-foot {
display: flex;
min-height: 3rem;
align-items: center;
@ -975,7 +984,7 @@ export default {
color: var(--overview-muted);
font-size: .6875rem;
}
.income-detail-foot button {
.flow-detail-foot button {
padding: .35rem .8rem;
border: 1px solid #cfc2ad;
border-radius: .35rem;
@ -984,8 +993,8 @@ export default {
font: 600 .6875rem/1.4 inherit;
cursor: pointer;
}
.income-detail-foot button:hover,
.income-detail-foot button:focus-visible {
.flow-detail-foot button:hover,
.flow-detail-foot button:focus-visible {
border-color: var(--overview-gold);
outline: none;
}
@ -1015,27 +1024,27 @@ export default {
.overview-cards {
grid-template-columns: 1fr;
}
.income-detail-modal {
.flow-detail-modal {
align-items: stretch;
padding: .5rem;
}
.income-detail-dialog {
.flow-detail-dialog {
max-height: calc(100vh - 1rem);
}
.income-detail-head {
.flow-detail-head {
align-items: stretch;
flex-wrap: wrap;
}
.income-detail-total {
.flow-detail-total {
order: 3;
width: 100%;
margin-left: 0;
text-align: left;
}
.income-detail-close {
.flow-detail-close {
margin-left: auto;
}
.income-detail-filters {
.flow-detail-filters {
grid-template-columns: 1fr;
}
}

View File

@ -41,6 +41,8 @@ Route::get('ecommerce-finance/account-bills/overview', [StoreReceivingBalanceOve
->name('ecommerce-finance.account-bills.overview');
Route::get('ecommerce-finance/account-bills/overview/income-detail', [StoreReceivingBalanceOverview::class, 'incomeDetail'])
->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/alipay-bills', [AlipayBills::class, 'index'])
->name('ecommerce-finance.alipay-bills.index');
Route::get('ecommerce-finance/alipay-bills/export', [AlipayBills::class, 'export'])

View File

@ -94,4 +94,41 @@ class StoreReceivingBalanceOverviewEndpointTest extends FeatureTestCase
->assertJsonPath('details.0.platform_slug', 'taobao-alipay')
->assertJsonPath('details.0.bill_item', '货款收入');
}
public function test_expense_detail_endpoint_returns_platform_store_and_bill_item_rows(): 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' => [
'expense' => 88.0,
'platform_count' => 8,
'available_platform_count' => 8,
'complete' => true,
],
'details' => [[
'platform_slug' => 'jd-wallet',
'platform' => '京东网银钱包账户',
'store_name' => '测试店铺',
'bill_item' => '平台扣费',
'amount' => 88.0,
]],
];
$service = Mockery::mock(StoreReceivingBalanceOverviewService::class);
$service->shouldReceive('expenseDetail')->once()->with('2026-10')->andReturn($payload);
$this->app->instance(StoreReceivingBalanceOverviewService::class, $service);
$this->loginAs()
->getJson(route('ecommerce-finance.account-bills.overview.expense-detail', [
'month' => '2026-10',
]))
->assertOk()
->assertJsonPath('summary.expense', 88)
->assertJsonPath('details.0.platform_slug', 'jd-wallet')
->assertJsonPath('details.0.bill_item', '平台扣费');
}
}

View File

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

View File

@ -31,11 +31,15 @@ class StoreReceivingBalanceOverviewTest extends TestCase
$this->assertStringContainsString('}万`;', $source);
$this->assertStringContainsString(':title="card.title"', $source);
$this->assertStringContainsString('查看明细', $source);
$this->assertStringContainsString('收入合计 · 明细汇总', $source);
$this->assertStringContainsString('openDetails(card.key)', $source);
$this->assertStringContainsString('detailTitle', $source);
$this->assertStringContainsString('收入合计', $source);
$this->assertStringContainsString('支出合计', $source);
$this->assertStringContainsString("{ key: 'platform', label: '平台' }", $source);
$this->assertStringContainsString("{ key: 'store', label: '店铺' }", $source);
$this->assertStringContainsString("{ key: 'bill_item', label: '账单项目' }", $source);
$this->assertStringContainsString('/income-detail', $source);
$this->assertStringContainsString('income-detail', $source);
$this->assertStringContainsString('expense-detail', $source);
$this->assertStringContainsString('当前筛选合计(万元)', $source);
}