From f99a9eb449032d76da9991d13dce6c48eeecac0b Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Mon, 9 Nov 2026 22:27:19 +0800 Subject: [PATCH] feat: add expense detail summary to account balance overview (#1023) --- .../StoreReceivingBalanceOverview.php | 20 + .../StoreReceivingBalanceOverviewService.php | 87 ++++- .../StoreReceivingBalanceOverview.vue | 351 +++++++++--------- routes/admin.php | 2 + ...reReceivingBalanceOverviewEndpointTest.php | 37 ++ ...oreReceivingBalanceOverviewServiceTest.php | 48 +++ .../StoreReceivingBalanceOverviewTest.php | 8 +- 7 files changed, 362 insertions(+), 191 deletions(-) diff --git a/modules/EcommerceFinance/Http/Controllers/StoreReceivingBalanceOverview.php b/modules/EcommerceFinance/Http/Controllers/StoreReceivingBalanceOverview.php index b5e897e0..e239bee7 100644 --- a/modules/EcommerceFinance/Http/Controllers/StoreReceivingBalanceOverview.php +++ b/modules/EcommerceFinance/Http/Controllers/StoreReceivingBalanceOverview.php @@ -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())) diff --git a/modules/EcommerceFinance/Services/StoreReceivingBalanceOverviewService.php b/modules/EcommerceFinance/Services/StoreReceivingBalanceOverviewService.php index c5330f74..0d3b7bd5 100644 --- a/modules/EcommerceFinance/Services/StoreReceivingBalanceOverviewService.php +++ b/modules/EcommerceFinance/Services/StoreReceivingBalanceOverviewService.php @@ -134,24 +134,51 @@ class StoreReceivingBalanceOverviewService * @return array */ public function incomeDetail(string $month): array + { + return $this->flowDetail($month, 'income'); + } + + /** + * 支出合计的平台、店铺、账单项目明细。 + * + * 支出明细沿用各平台账单页的同一数据源和账期口径,只汇总支出行; + * 明细金额统一返回元,页面再按万元展示。 + * + * @return array + */ + public function expenseDetail(string $month): array + { + return $this->flowDetail($month, 'expense'); + } + + /** @return array */ + 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 */ protected function fetchPlatformIncomeRows(string $platform, string $start, string $end): iterable + { + return $this->fetchPlatformRows($platform, $start, $end, 'income'); + } + + /** + * 返回某平台所选月份的支出行。 + * + * @return iterable> + */ + protected function fetchPlatformExpenseRows(string $platform, string $start, string $end): iterable + { + return $this->fetchPlatformRows($platform, $start, $end, 'expense'); + } + + /** @return iterable> */ + 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 + * @return iterable> */ - 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, ]; } } diff --git a/resources/assets/js/components/business/StoreReceivingBalanceOverview.vue b/resources/assets/js/components/business/StoreReceivingBalanceOverview.vue index 16d6e614..fc4cd7b5 100644 --- a/resources/assets/js/components/business/StoreReceivingBalanceOverview.vue +++ b/resources/assets/js/components/business/StoreReceivingBalanceOverview.vue @@ -28,12 +28,12 @@ @@ -61,117 +61,117 @@ @@ -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; } } diff --git a/routes/admin.php b/routes/admin.php index e7d25422..f3b92e10 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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']) diff --git a/tests/Feature/Business/StoreReceivingBalanceOverviewEndpointTest.php b/tests/Feature/Business/StoreReceivingBalanceOverviewEndpointTest.php index f88bb408..20fd7ace 100644 --- a/tests/Feature/Business/StoreReceivingBalanceOverviewEndpointTest.php +++ b/tests/Feature/Business/StoreReceivingBalanceOverviewEndpointTest.php @@ -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', '平台扣费'); + } } diff --git a/tests/Unit/EcommerceFinance/StoreReceivingBalanceOverviewServiceTest.php b/tests/Unit/EcommerceFinance/StoreReceivingBalanceOverviewServiceTest.php index 2b2b3566..1336f62b 100644 --- a/tests/Unit/EcommerceFinance/StoreReceivingBalanceOverviewServiceTest.php +++ b/tests/Unit/EcommerceFinance/StoreReceivingBalanceOverviewServiceTest.php @@ -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 { diff --git a/tests/Unit/View/StoreReceivingBalanceOverviewTest.php b/tests/Unit/View/StoreReceivingBalanceOverviewTest.php index bbc6c725..cb524d84 100644 --- a/tests/Unit/View/StoreReceivingBalanceOverviewTest.php +++ b/tests/Unit/View/StoreReceivingBalanceOverviewTest.php @@ -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); }