费用明细:紧凑汇总布局并按开票口径拆分 #513

Merged
yueyang merged 1 commits from feature/yueyang-expense-layout-invoice-summary into main 2026-10-24 19:02:54 +08:00
4 changed files with 129 additions and 54 deletions

View File

@ -357,7 +357,12 @@ class BillingDetailsPageData
/**
* 费用明细的账单项目汇总:只响应汇总请求,避免拖慢主表首屏。
*
* @return array{items:list<array{item:string,rows:int,net:float}>,item_count:int,rows:int,net:float}
* @return array{
* items:list<array{item:string,invoice_net:float,non_invoice_net:float,net:float}>,
* item_count:int,
* invoice_net:float,
* non_invoice_net:float
* }
*/
public function billItemSummary(?array $input = null, array $forcedFilters = []): array
{
@ -366,39 +371,75 @@ class BillingDetailsPageData
$signature = md5((string) json_encode($filters, JSON_UNESCAPED_UNICODE));
return cache()->remember(
'billing-details-item-summary-v1:' . $signature,
'billing-details-item-summary-v2:' . $signature,
now()->addMinutes(2),
function () use ($filters): array {
$connection = $this->connection();
$rulesByPlatform = $this->expenseRulesByPlatform();
$parts = [];
$bind = [];
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
$invoiceConditions = [];
$nonInvoiceConditions = [];
foreach (self::BRANCH_PLATFORMS[$label] ?? [] as $platform) {
$invoiceItems = array_keys($rulesByPlatform[$platform]['invoice_expense'] ?? []);
if ($invoiceItems !== []) {
$invoiceConditions[] = '(' . $this->storePrefixCondition($platform)
. ' AND ' . $this->normalizedItemSql()
. ' IN (' . $this->literals($invoiceItems) . '))';
}
$nonInvoiceItems = array_keys($rulesByPlatform[$platform]['non_invoice_expense'] ?? []);
if ($nonInvoiceItems !== []) {
$nonInvoiceConditions[] = '(' . $this->storePrefixCondition($platform)
. ' AND ' . $this->normalizedItemSql()
. ' IN (' . $this->literals($nonInvoiceItems) . '))';
}
}
if ($invoiceConditions === [] && $nonInvoiceConditions === []) {
continue;
}
$bucketSql = 'CASE';
if ($invoiceConditions !== []) {
$bucketSql .= ' WHEN (' . implode(' OR ', $invoiceConditions)
. ") THEN 'invoice'";
}
if ($nonInvoiceConditions !== []) {
$bucketSql .= ' WHEN (' . implode(' OR ', $nonInvoiceConditions)
. ") THEN 'non_invoice'";
}
$bucketSql .= " ELSE 'unknown' END";
foreach ($branch['scopes'] as $scope) {
$parts[] = 'SELECT bill_item, COUNT(*) AS c, '
$parts[] = 'SELECT ' . $bucketSql . ' AS invoice_bucket, bill_item, '
. 'COALESCE(SUM(' . $branch['income'] . '),0) AS inc, '
. 'COALESCE(SUM(' . $branch['expense'] . '),0) AS exp '
. 'FROM ' . $branch['table'] . ' WHERE ' . $scope['where']
. ' GROUP BY bill_item';
. ' GROUP BY invoice_bucket, bill_item';
$bind = array_merge($bind, $scope['bind']);
}
}
$totals = [];
$rows = 0;
$net = 0.0;
$invoiceNet = 0.0;
$nonInvoiceNet = 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;
$bucket = (string) ($part->invoice_bucket ?? '');
if (! in_array($bucket, ['invoice', 'non_invoice'], true)) {
continue;
}
$net = (float) $part->inc - (float) $part->exp;
if ($bucket === 'invoice') {
$invoiceNet += $net;
} else {
$nonInvoiceNet += $net;
}
$this->accumulateBillItemTotal(
$totals,
(string) ($part->bill_item ?? ''),
$partRows,
$partIncome,
$partExpense,
$net,
$bucket,
);
}
@ -407,40 +448,41 @@ class BillingDetailsPageData
return [
'items' => $items,
'item_count' => count($items),
'rows' => $rows,
'net' => round($net, 2),
'invoice_net' => round($invoiceNet, 2),
'non_invoice_net' => round($nonInvoiceNet, 2),
];
},
);
}
/** @param array<string,array{rows:int,income:float,expense:float}> $totals */
/** @param array<string,array{invoice_net:float,non_invoice_net:float}> $totals */
private function accumulateBillItemTotal(
array &$totals,
string $billItem,
int $rows,
float $income,
float $expense,
float $net,
string $invoiceBucket,
): 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;
$totals[$billItem] ??= ['invoice_net' => 0.0, 'non_invoice_net' => 0.0];
$key = $invoiceBucket === 'invoice' ? 'invoice_net' : 'non_invoice_net';
$totals[$billItem][$key] += $net;
}
/**
* @param array<string,array{rows:int,income:float,expense:float}> $totals
* @return list<array{item:string,rows:int,net:float}>
* @param array<string,array{invoice_net:float,non_invoice_net:float}> $totals
* @return list<array{item:string,invoice_net:float,non_invoice_net:float,net:float}>
*/
private function sortedBillItemTotals(array $totals): array
{
$items = [];
foreach ($totals as $item => $total) {
$invoiceNet = round((float) $total['invoice_net'], 2);
$nonInvoiceNet = round((float) $total['non_invoice_net'], 2);
$items[] = [
'item' => $item,
'rows' => (int) $total['rows'],
'net' => round((float) $total['income'] - (float) $total['expense'], 2),
'invoice_net' => $invoiceNet,
'non_invoice_net' => $nonInvoiceNet,
'net' => round($invoiceNet + $nonInvoiceNet, 2),
];
}
usort($items, static function (array $left, array $right): int {

View File

@ -26,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-page-intro { order:1; }
.pd-page-title-row { display:flex; flex-wrap:wrap; align-items:center; gap:.65rem 1rem; margin:.25rem 0 .5rem; }
.pd-page-title-row h2 { margin:0; font-size:1.05rem; }
.pd-page-description { color:#595959; font-size:.8125rem; margin-bottom:.75rem; }
.pd-compact-summary { display:flex; flex-wrap:wrap; align-items:center; gap:.35rem .85rem; margin-left:auto; padding:.35rem .6rem; border:1px solid #e3dac9; border-radius:.45rem; background:#faf6ee; color:#595959; font-size:.75rem; }
.pd-compact-summary span { display:inline-flex; align-items:baseline; gap:.25rem; white-space:nowrap; }
.pd-compact-summary strong { color:#2f2a23; font-size:.8125rem; font-variant-numeric:tabular-nums; }
.expense-details-page { display:flex; flex-direction:column; }
.expense-details-page #pdItemSummary { order:2; }
.expense-details-page #pdFilterForm { order:3; }
.expense-details-page .pd-detail-list { order:4; }
.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; }
@ -107,19 +118,31 @@
.pd-export-progress__bar { height:.5rem; border:1px solid #d8d2c7; border-radius:999px; background:#fff; overflow:hidden; }
.pd-export-progress__bar span { display:block; height:100%; width:0; background:#856632; transition:width .3s ease; }
.pd-export-progress__text { margin-top:.35rem; color:#595959; font-size:.75rem; }
@media (max-width:900px) { .pd-stats { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width:900px) { .pd-stats { grid-template-columns:repeat(2, minmax(0, 1fr)); } .pd-compact-summary { margin-left:0; } }
@media (max-width:520px) { .pd-stats { grid-template-columns:1fr; } }
</style>
<h2 style="margin:.25rem 0 .5rem;font-size:1.05rem;">{{ $pageTitle }}</h2>
<div style="color:#595959;font-size:.8125rem;margin-bottom:.75rem;">
数据源=各平台已导入账单(与「店铺收款账户账单」同源);<b>财务备注</b>、<b>财务类型</b>由科目映射表按「平台+账单项目(wx 自动去前缀)」带出,匹配不到的留空并标红。
@if ($expenseOnly)
本页只展示科目映射表中<b>财务类型=费用</b>的账单;账单项目按当前筛选汇总,金额单位为<b>万</b>。
@else
同一交易单号跨月合并后已与订单实付对平的,差额备注优先显示为<b>差额=跨月收款(合并已对平)</b>;未对平仍显示<b>差额待核实</b>。
@endif
账单项目不再单独筛选,直接在<b>关键词</b>里输入即可(关键词同时匹配记录编码 / 订单号 / 账单项目 / 商户订单号 / 备注)。
<div class="pd-page-intro">
<div class="pd-page-title-row">
<h2>{{ $pageTitle }}</h2>
@if ($expenseOnly)
<div class="pd-compact-summary" aria-label="费用汇总">
<span>收入 <strong>¥{{ number_format($summary['income'], 2) }}</strong></span>
<span>支出 <strong>¥{{ number_format($summary['expense'], 2) }}</strong></span>
<span>差额 <strong>¥{{ number_format($summary['net'], 2) }}</strong></span>
<span>笔数 <strong>{{ number_format($summary['rows']) }}</strong></span>
</div>
@endif
</div>
<div class="pd-page-description">
数据源=各平台已导入账单(与「店铺收款账户账单」同源);<b>财务备注</b>、<b>财务类型</b>由科目映射表按「平台+账单项目(wx 自动去前缀)」带出,匹配不到的留空并标红。
@if ($expenseOnly)
本页只展示科目映射表中<b>财务类型=费用</b>的账单;账单项目按当前筛选汇总,金额单位为<b>万</b>。
@else
同一交易单号跨月合并后已与订单实付对平的,差额备注优先显示为<b>差额=跨月收款(合并已对平)</b>;未对平仍显示<b>差额待核实</b>。
@endif
账单项目不再单独筛选,直接在<b>关键词</b>里输入即可(关键词同时匹配记录编码 / 订单号 / 账单项目 / 商户订单号 / 备注)。
</div>
</div>
<form class="upstream-data-toolbar pd-filter-grid" method="get" id="pdFilterForm" autocomplete="off">
@ -206,6 +229,7 @@
</div>
</form>
@if (! $expenseOnly)
<div class="pd-stats">
<div class="pd-stat">
<div class="pd-stat__label">收入合计</div>
@ -228,6 +252,7 @@
<div class="pd-stat__note">当前筛选结果</div>
</div>
</div>
@endif
@if ($expenseOnly)
<div class="pd-item-summary" id="pdItemSummary" data-endpoint="{{ $expenseItemSummaryRoute }}">
@ -241,16 +266,16 @@
<thead>
<tr>
<th>账单项目</th>
<th class="num">汇总金额(万)</th>
<th class="num">笔数</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>
<td class="num" id="pdItemSummaryTotalInvoice">—</td>
<td class="num" id="pdItemSummaryTotalNonInvoice">—</td>
</tr>
</tfoot>
</table>
@ -258,6 +283,7 @@
</div>
@endif
<div class="pd-detail-list">
@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
@ -349,6 +375,7 @@
</div>
</div>
@endif
</div>
<script>
(function () {
@ -624,8 +651,8 @@
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 summaryTotalInvoice = document.getElementById('pdItemSummaryTotalInvoice');
var summaryTotalNonInvoice = document.getElementById('pdItemSummaryTotalNonInvoice');
var summaryParams = new URLSearchParams(window.location.search);
summaryParams.delete('page');
var summaryUrl = itemSummaryBox.getAttribute('data-endpoint')
@ -648,16 +675,17 @@
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) {
[item.item, formatWan(item.invoice_net), formatWan(item.non_invoice_net)]
.forEach(function (value, index) {
var td = document.createElement('td');
td.textContent = value;
if (index > 0) td.className = 'num';
tr.appendChild(td);
});
summaryRows.appendChild(tr);
});
summaryTotalNet.textContent = formatWan(summary.net);
summaryTotalRows.textContent = Number(summary.rows || 0).toLocaleString('zh-CN');
summaryTotalInvoice.textContent = formatWan(summary.invoice_net);
summaryTotalNonInvoice.textContent = formatWan(summary.non_invoice_net);
summaryStatus.textContent = Number(summary.item_count || 0) > 0
? (Number(summary.item_count).toLocaleString('zh-CN') + ' 个账单项目')
: '当前筛选没有费用项目';

View File

@ -144,6 +144,10 @@ class NavigationTest extends FeatureTestCase
->assertSee('/workspace/financial-operations/expense-details/filter-options', false)
->assertSee('/workspace/financial-operations/expense-details/item-summary', false)
->assertSee('id="pdItemSummary"', false)
->assertSee('pd-compact-summary', false)
->assertSeeText('开票金额(万)')
->assertSeeText('不开票金额(万)')
->assertDontSee('<div class="pd-stats">', false)
->assertDontSee('id="pdType"', false);
}

View File

@ -18,23 +18,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
public function test_bill_item_totals_merge_wx_prefix_and_split_invoice_buckets(): 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]);
$accumulate->invokeArgs($data, [&$totals, 'wx技术服务费', -90.0, 'invoice']);
$accumulate->invokeArgs($data, [&$totals, '技术服务费', -10.0, 'invoice']);
$accumulate->invokeArgs($data, [&$totals, '技术服务费', -5.0, 'non_invoice']);
$accumulate->invokeArgs($data, [&$totals, '', -0.5, 'non_invoice']);
$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],
['item' => '技术服务费', 'invoice_net' => -100.0, 'non_invoice_net' => -5.0, 'net' => -105.0],
['item' => '未填写账单项目', 'invoice_net' => 0.0, 'non_invoice_net' => -0.5, 'net' => -0.5],
], $rows);
}
}