feat: add expense detail summary to account balance overview (#1023)
This commit is contained in:
parent
449c010c13
commit
f99a9eb449
@ -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()))
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> </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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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', '平台扣费');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user