费用明细:新增账单项目万元汇总 #509

Merged
yueyang merged 1 commits from feature/yueyang-expense-item-summary into main 2026-10-24 18:16:31 +08:00
6 changed files with 245 additions and 2 deletions

View File

@ -1198,6 +1198,30 @@ class Pages extends Controller
]);
}
/** 费用明细的账单项目汇总(异步请求,避免拖慢主表首屏)。 */
public function expenseDetailsItemSummary(Request $request): \Illuminate\Http\JsonResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'expense-details'), 403, '当前账号没有该页面权限。');
try {
$summary = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)
->billItemSummary($request->all(), ['type' => ['费用']]);
} catch (\Throwable $exception) {
report($exception);
return response()->json([
'message' => '账单项目汇总暂时无法读取,请稍后重试。',
], 503, [
'Cache-Control' => 'private, no-store',
]);
}
return response()->json($summary, 200, [
'Cache-Control' => 'private, no-store',
]);
}
/** 账单明细导出进度(前端每秒轮询一次) */
public function billingDetailsExportProgress(Request $request): \Illuminate\Http\JsonResponse
{

View File

@ -354,6 +354,106 @@ class BillingDetailsPageData
];
}
/**
* 费用明细的账单项目汇总:只响应汇总请求,避免拖慢主表首屏。
*
* @return array{items:list<array{item:string,rows:int,net:float}>,item_count:int,rows:int,net:float}
*/
public function billItemSummary(?array $input = null, array $forcedFilters = []): array
{
$input ??= request()->all();
$filters = array_replace($this->parseFilters($input), $forcedFilters);
$signature = md5((string) json_encode($filters, JSON_UNESCAPED_UNICODE));
return cache()->remember(
'billing-details-item-summary-v1:' . $signature,
now()->addMinutes(2),
function () use ($filters): array {
$connection = $this->connection();
$parts = [];
$bind = [];
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
foreach ($branch['scopes'] as $scope) {
$parts[] = 'SELECT bill_item, COUNT(*) AS c, '
. 'COALESCE(SUM(' . $branch['income'] . '),0) AS inc, '
. 'COALESCE(SUM(' . $branch['expense'] . '),0) AS exp '
. 'FROM ' . $branch['table'] . ' WHERE ' . $scope['where']
. ' GROUP BY bill_item';
$bind = array_merge($bind, $scope['bind']);
}
}
$totals = [];
$rows = 0;
$net = 0.0;
foreach ($connection->select(implode(' UNION ALL ', $parts), $bind) as $part) {
$partRows = (int) $part->c;
$partIncome = (float) $part->inc;
$partExpense = (float) $part->exp;
$rows += $partRows;
$net += $partIncome - $partExpense;
$this->accumulateBillItemTotal(
$totals,
(string) ($part->bill_item ?? ''),
$partRows,
$partIncome,
$partExpense,
);
}
$items = $this->sortedBillItemTotals($totals);
return [
'items' => $items,
'item_count' => count($items),
'rows' => $rows,
'net' => round($net, 2),
];
},
);
}
/** @param array<string,array{rows:int,income:float,expense:float}> $totals */
private function accumulateBillItemTotal(
array &$totals,
string $billItem,
int $rows,
float $income,
float $expense,
): void {
$billItem = $this->normalizeItem($billItem) ?: '未填写账单项目';
$totals[$billItem] ??= ['rows' => 0, 'income' => 0.0, 'expense' => 0.0];
$totals[$billItem]['rows'] += $rows;
$totals[$billItem]['income'] += $income;
$totals[$billItem]['expense'] += $expense;
}
/**
* @param array<string,array{rows:int,income:float,expense:float}> $totals
* @return list<array{item:string,rows:int,net:float}>
*/
private function sortedBillItemTotals(array $totals): array
{
$items = [];
foreach ($totals as $item => $total) {
$items[] = [
'item' => $item,
'rows' => (int) $total['rows'],
'net' => round((float) $total['income'] - (float) $total['expense'], 2),
];
}
usort($items, static function (array $left, array $right): int {
if (abs($left['net'] - $right['net']) >= 0.005) {
return $left['net'] <=> $right['net'];
}
return strnatcasecmp($left['item'], $right['item']);
});
return $items;
}
/**
* 财务类型为「费用」的账单金额,按财务经营分析表的「平台-店铺」及票据口径汇总。
*

View File

@ -10,6 +10,9 @@
$exportProgressRoute = $expenseOnly
? route('business.financial-operations.expense-details.export-progress')
: route('business.financial-operations.billing-details.export-progress');
$expenseItemSummaryRoute = $expenseOnly
? route('business.financial-operations.expense-details.item-summary')
: null;
@endphp
<div class="upstream-data-page billing-details-page {{ $expenseOnly ? 'expense-details-page' : '' }}" style="padding:0;">
@include('business.pages.partials.upstream-data-styles')
@ -23,6 +26,17 @@
.pd-stat__note { margin-top:.1875rem; color:#595959; font-size:.75rem; }
.pd-stat .is-income { color:#53793e; }
.pd-stat .is-expense { color:#b80000; }
.pd-item-summary { margin:0 0 1rem; border:1px solid #d8d2c7; border-radius:.75rem; background:#fff; overflow:hidden; }
.pd-item-summary__head { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .875rem; padding:.75rem 1rem; border-bottom:1px solid #e8e0d2; background:#faf6ee; }
.pd-item-summary__head strong { color:#2f2a23; }
.pd-item-summary__head span { color:#7c7468; font-size:.75rem; }
.pd-item-summary__wrap { max-height:19rem; overflow:auto; }
.pd-item-summary table { width:100%; border-collapse:collapse; font-size:.8125rem; }
.pd-item-summary th { position:sticky; top:0; z-index:1; padding:.55rem .8rem; border-bottom:1px solid #d8d2c7; background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; }
.pd-item-summary th.num, .pd-item-summary td.num { text-align:right; }
.pd-item-summary td { padding:.5rem .8rem; border-bottom:1px solid #eee7da; color:#3d3a35; }
.pd-item-summary td.num { font-variant-numeric:tabular-nums; white-space:nowrap; }
.pd-item-summary tfoot td { position:sticky; bottom:0; z-index:1; border-top:1px solid #d8d2c7; background:#fff8e9; font-weight:700; }
/* 筛选栏:12 列栅格对齐——一行 4 个下拉;账单项目与关键词同一行;发生时间单独一行 */
.pd-filter-grid { display:grid; grid-template-columns:repeat(12, minmax(0, 1fr)); gap:.75rem 1rem; align-items:end; }
.pd-filter-grid .upstream-data-field { min-width:0; }
@ -101,7 +115,7 @@
<div style="color:#595959;font-size:.8125rem;margin-bottom:.75rem;">
数据源=各平台已导入账单(与「店铺收款账户账单」同源);<b>财务备注</b>、<b>财务类型</b>由科目映射表按「平台+账单项目(wx 自动去前缀)」带出,匹配不到的留空并标红。
@if ($expenseOnly)
本页只展示科目映射表中<b>财务类型=费用</b>的账单。
本页只展示科目映射表中<b>财务类型=费用</b>的账单;账单项目按当前筛选汇总,金额单位为<b>万</b>。
@else
同一交易单号跨月合并后已与订单实付对平的,差额备注优先显示为<b>差额=跨月收款(合并已对平)</b>;未对平仍显示<b>差额待核实</b>。
@endif
@ -215,6 +229,35 @@
</div>
</div>
@if ($expenseOnly)
<div class="pd-item-summary" id="pdItemSummary" data-endpoint="{{ $expenseItemSummaryRoute }}">
<div class="pd-item-summary__head">
<strong>账单项目汇总</strong>
<span>单位:万;汇总金额 = 收入 - 支出</span>
<span id="pdItemSummaryStatus">正在汇总…</span>
</div>
<div class="pd-item-summary__wrap" id="pdItemSummaryWrap" hidden>
<table>
<thead>
<tr>
<th>账单项目</th>
<th class="num">汇总金额(万)</th>
<th class="num">笔数</th>
</tr>
</thead>
<tbody id="pdItemSummaryRows"></tbody>
<tfoot>
<tr>
<td>合计</td>
<td class="num" id="pdItemSummaryTotalNet">—</td>
<td class="num" id="pdItemSummaryTotalRows">—</td>
</tr>
</tfoot>
</table>
</div>
</div>
@endif
@if (! empty($error))
<div style="padding:.75rem 1rem;border:1px solid #edcccc;border-radius:.75rem;background:#fdf3f3;color:#a32d2d;font-size:.8125rem;">{{ $error }}</div>
@else
@ -576,6 +619,55 @@
syncButtonLabel();
applyColumns();
var itemSummaryBox = document.getElementById('pdItemSummary');
if (itemSummaryBox) {
var summaryStatus = document.getElementById('pdItemSummaryStatus');
var summaryWrap = document.getElementById('pdItemSummaryWrap');
var summaryRows = document.getElementById('pdItemSummaryRows');
var summaryTotalNet = document.getElementById('pdItemSummaryTotalNet');
var summaryTotalRows = document.getElementById('pdItemSummaryTotalRows');
var summaryParams = new URLSearchParams(window.location.search);
summaryParams.delete('page');
var summaryUrl = itemSummaryBox.getAttribute('data-endpoint')
+ (summaryParams.toString() ? '?' + summaryParams.toString() : '');
var formatWan = function (value) {
return (Number(value || 0) / 10000).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
};
fetch(summaryUrl, { headers: { Accept: 'application/json' } })
.then(function (response) {
return response.json().then(function (payload) {
if (!response.ok) throw new Error(payload.message || '汇总读取失败');
return payload;
});
})
.then(function (summary) {
summaryRows.replaceChildren();
(summary.items || []).forEach(function (item) {
var tr = document.createElement('tr');
[item.item, formatWan(item.net), Number(item.rows || 0).toLocaleString('zh-CN')]
.forEach(function (value) {
var td = document.createElement('td');
td.textContent = value;
tr.appendChild(td);
});
summaryRows.appendChild(tr);
});
summaryTotalNet.textContent = formatWan(summary.net);
summaryTotalRows.textContent = Number(summary.rows || 0).toLocaleString('zh-CN');
summaryStatus.textContent = Number(summary.item_count || 0) > 0
? (Number(summary.item_count).toLocaleString('zh-CN') + ' 个账单项目')
: '当前筛选没有费用项目';
summaryWrap.hidden = Number(summary.item_count || 0) === 0;
})
.catch(function (error) {
summaryStatus.textContent = error.message || '汇总暂时无法读取';
});
}
// 每页条数:改完回到第 1 页
var perPageEl = document.getElementById('pdPerPage');
if (perPageEl) {

View File

@ -599,6 +599,10 @@ Route::get('workspace/financial-operations/expense-details/filter-options', 'Bus
->middleware('business.page:financial-operations,expense-details')
->name('business.financial-operations.expense-details.filter-options');
Route::get('workspace/financial-operations/expense-details/item-summary', 'Business\Pages@expenseDetailsItemSummary')
->middleware('business.page:financial-operations,expense-details')
->name('business.financial-operations.expense-details.item-summary');
Route::get('workspace/financial-operations/billing-details/export', 'Business\Pages@exportBillingDetails')
->middleware('business.page:financial-operations,billing-details')
->name('business.financial-operations.billing-details.export');

View File

@ -139,9 +139,11 @@ class NavigationTest extends FeatureTestCase
]))
->assertOk()
->assertSeeText('费用明细')
->assertSeeText('本页只展示科目映射表中财务类型=费用的账单。')
->assertSeeText('本页只展示科目映射表中财务类型=费用的账单;账单项目按当前筛选汇总,金额单位为万。')
->assertSee('/workspace/financial-operations/expense-details/export', false)
->assertSee('/workspace/financial-operations/expense-details/filter-options', false)
->assertSee('/workspace/financial-operations/expense-details/item-summary', false)
->assertSee('id="pdItemSummary"', false)
->assertDontSee('id="pdType"', false);
}

View File

@ -3,6 +3,7 @@
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\View\BillingDetailsPageData;
use ReflectionMethod;
use Tests\TestCase;
class BillingDetailsExpenseFilterTest extends TestCase
@ -16,4 +17,24 @@ class BillingDetailsExpenseFilterTest extends TestCase
$this->assertSame(['费用'], $filters['type']);
}
public function test_bill_item_totals_merge_wx_prefix_and_keep_income_minus_expense_basis(): void
{
$data = app(BillingDetailsPageData::class);
$totals = [];
$accumulate = new ReflectionMethod(BillingDetailsPageData::class, 'accumulateBillItemTotal');
$accumulate->setAccessible(true);
$accumulate->invokeArgs($data, [&$totals, 'wx技术服务费', 2, 10.0, 100.0]);
$accumulate->invokeArgs($data, [&$totals, '技术服务费', 1, 5.0, 20.0]);
$accumulate->invokeArgs($data, [&$totals, '', 1, 0.0, 0.5]);
$sorted = new ReflectionMethod(BillingDetailsPageData::class, 'sortedBillItemTotals');
$sorted->setAccessible(true);
$rows = $sorted->invoke($data, $totals);
$this->assertSame([
['item' => '技术服务费', 'rows' => 3, 'net' => -105.0],
['item' => '未填写账单项目', 'rows' => 1, 'net' => -0.5],
], $rows);
}
}