费用分类弹窗增加平台汇总

费用分类弹窗增加平台汇总
This commit is contained in:
yueyang 2026-10-25 14:50:52 +08:00
parent 98436bfd0a
commit c9f5e358c4
4 changed files with 161 additions and 20 deletions

View File

@ -373,6 +373,7 @@ class BillingDetailsPageData
* invoice_net:float,
* non_invoice_net:float,
* net:float,
* platforms:list<array{platform:string,invoice_net:float,non_invoice_net:float,net:float}>,
* items:list<array{bill_item:string,invoice_net:float,non_invoice_net:float,net:float}>
* }>,
* category_count:int,
@ -388,7 +389,7 @@ class BillingDetailsPageData
$signature = md5((string) json_encode($filters, JSON_UNESCAPED_UNICODE));
return cache()->remember(
'billing-details-item-summary-v5:' . $signature,
'billing-details-item-summary-v6:' . $signature,
now()->addMinutes(2),
function () use ($filters): array {
$connection = $this->connection();
@ -398,6 +399,7 @@ class BillingDetailsPageData
$bind = [];
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
$platformSql = $this->expensePlatformSql($label);
$invoiceConditions = [];
$nonInvoiceConditions = [];
$memoConditions = [];
@ -447,11 +449,12 @@ class BillingDetailsPageData
foreach ($branch['scopes'] as $scope) {
$parts[] = 'SELECT ' . $memoSql . ' AS ledger_memo, '
. $platformSql . ' AS platform_label, '
. $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 ledger_memo, invoice_bucket, bill_item';
. ' GROUP BY ledger_memo, platform_label, invoice_bucket, bill_item';
$bind = array_merge($bind, $scope['bind']);
}
}
@ -473,6 +476,7 @@ class BillingDetailsPageData
$this->accumulateLedgerMemoTotal(
$totals,
(string) ($part->ledger_memo ?? ''),
(string) ($part->platform_label ?? ''),
(string) ($part->bill_item ?? ''),
$net,
$bucket,
@ -496,22 +500,32 @@ class BillingDetailsPageData
* @param array<string,array{
* invoice_net:float,
* non_invoice_net:float,
* platforms:array<string,array{invoice_net:float,non_invoice_net:float}>,
* items:array<string,array{invoice_net:float,non_invoice_net:float}>
* }> $totals
*/
private function accumulateLedgerMemoTotal(
array &$totals,
string $ledgerMemo,
string $platform,
string $billItem,
float $net,
string $invoiceBucket,
): void {
$ledgerMemo = trim($ledgerMemo) ?: '未填写账单费用';
$platform = trim($platform) ?: '未填写平台';
$billItem = $this->normalizeItem($billItem) ?: '未填写账单项目';
$totals[$ledgerMemo] ??= ['invoice_net' => 0.0, 'non_invoice_net' => 0.0, 'items' => []];
$totals[$ledgerMemo] ??= [
'invoice_net' => 0.0,
'non_invoice_net' => 0.0,
'platforms' => [],
'items' => [],
];
$totals[$ledgerMemo]['platforms'][$platform] ??= ['invoice_net' => 0.0, 'non_invoice_net' => 0.0];
$totals[$ledgerMemo]['items'][$billItem] ??= ['invoice_net' => 0.0, 'non_invoice_net' => 0.0];
$key = $invoiceBucket === 'invoice' ? 'invoice_net' : 'non_invoice_net';
$totals[$ledgerMemo][$key] += $net;
$totals[$ledgerMemo]['platforms'][$platform][$key] += $net;
$totals[$ledgerMemo]['items'][$billItem][$key] += $net;
}
@ -519,6 +533,7 @@ class BillingDetailsPageData
* @param array<string,array{
* invoice_net:float,
* non_invoice_net:float,
* platforms:array<string,array{invoice_net:float,non_invoice_net:float}>,
* items:array<string,array{invoice_net:float,non_invoice_net:float}>
* }> $totals
* @return list<array{
@ -526,6 +541,7 @@ class BillingDetailsPageData
* invoice_net:float,
* non_invoice_net:float,
* net:float,
* platforms:list<array{platform:string,invoice_net:float,non_invoice_net:float,net:float}>,
* items:list<array{bill_item:string,invoice_net:float,non_invoice_net:float,net:float}>
* }>
*/
@ -535,6 +551,24 @@ class BillingDetailsPageData
foreach ($totals as $memo => $total) {
$invoiceNet = round((float) $total['invoice_net'], 2);
$nonInvoiceNet = round((float) $total['non_invoice_net'], 2);
$platforms = [];
foreach ($total['platforms'] as $platform => $platformTotal) {
$platformInvoiceNet = round((float) $platformTotal['invoice_net'], 2);
$platformNonInvoiceNet = round((float) $platformTotal['non_invoice_net'], 2);
$platforms[] = [
'platform' => $platform,
'invoice_net' => $platformInvoiceNet,
'non_invoice_net' => $platformNonInvoiceNet,
'net' => round($platformInvoiceNet + $platformNonInvoiceNet, 2),
];
}
usort($platforms, static function (array $left, array $right): int {
if (abs(abs($left['net']) - abs($right['net'])) >= 0.005) {
return abs($right['net']) <=> abs($left['net']);
}
return strnatcasecmp($left['platform'], $right['platform']);
});
$billItems = [];
foreach ($total['items'] as $billItem => $itemTotal) {
$itemInvoiceNet = round((float) $itemTotal['invoice_net'], 2);
@ -558,6 +592,7 @@ class BillingDetailsPageData
'invoice_net' => $invoiceNet,
'non_invoice_net' => $nonInvoiceNet,
'net' => round($invoiceNet + $nonInvoiceNet, 2),
'platforms' => $platforms,
'items' => $billItems,
];
}
@ -1534,6 +1569,28 @@ class BillingDetailsPageData
return $parts === [] ? '1 = 1' : '(' . implode(' OR ', $parts) . ')';
}
/**
* 账单来源分支 → 费用分类弹窗的平台名称。
*
* 同一来源可能混合多个平台(例如淘系支付宝包含天猫、淘宝、阿里巴巴、支付宝),
* 按店铺名前缀还原,无法识别时保留来源名称。
*/
private function expensePlatformSql(string $branchLabel): string
{
$conditions = [];
foreach (self::BRANCH_PLATFORMS[$branchLabel] ?? [] as $platform) {
$conditions[] = 'WHEN ' . $this->storePrefixCondition($platform)
. ' THEN ' . $this->literal($platform);
}
if ($conditions === []) {
return $this->literal($branchLabel);
}
return 'CASE ' . implode(' ', $conditions)
. ' ELSE ' . $this->literal($branchLabel) . ' END';
}
/**
* 取与查询区间有交集的账期的 active_generation(与 JD / 抖音账单页仓储同一口径)。
*

View File

@ -69,6 +69,11 @@
.pd-expense-dialog__close { display:inline-flex; align-items:center; justify-content:center; width:2rem; height:2rem; padding:0; border:1px solid #d8d2c7; border-radius:999px; background:#fff; color:#595959; font-size:1.25rem; cursor:pointer; }
.pd-expense-dialog__close:hover { background:#f7f0de; color:#856632; }
.pd-expense-dialog__body { max-height:calc(86vh - 6rem); overflow:auto; padding:0 1.25rem 1.25rem; }
.pd-expense-dialog__tabs { display:flex; align-items:center; gap:1.25rem; margin:0 -1.25rem; padding:0 1.25rem; border-bottom:1px solid #e8e0d2; background:#fff; }
.pd-expense-dialog__tab { min-height:2.75rem; padding:0 .2rem; border:0; border-bottom:2px solid transparent; background:transparent; color:#595959; font:inherit; font-size:.8125rem; font-weight:600; cursor:pointer; }
.pd-expense-dialog__tab:hover { color:#856632; }
.pd-expense-dialog__tab[aria-selected="true"] { border-bottom-color:#b9954f; color:#856632; }
.pd-expense-dialog__panel[hidden] { display:none; }
.pd-expense-dialog__table { width:100%; border-collapse:collapse; font-size:.875rem; }
.pd-expense-dialog__table th { position:sticky; top:0; z-index:1; padding:.65rem .85rem; border-bottom:1px solid #d8d2c7; background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; }
.pd-expense-dialog__table td { padding:.6rem .85rem; border-bottom:1px solid #eee7da; color:#3d3a35; }
@ -322,7 +327,7 @@
<div class="pd-expense-dialog__head">
<div class="pd-expense-dialog__head-main">
<h2 class="pd-expense-dialog__title" id="pdExpenseCategoryDialogTitle">费用分类明细</h2>
<span class="pd-expense-dialog__subtitle">按账单名称汇总;开票与不开票金额均以万元展示</span>
<span class="pd-expense-dialog__subtitle">按平台或账单名称查看当前费用分类明细,金额单位为万元</span>
</div>
<div class="pd-expense-dialog__total">
<span>当前分类合计(万元)</span>
@ -331,18 +336,36 @@
<button type="button" class="pd-expense-dialog__close" id="pdExpenseCategoryDialogClose" aria-label="关闭费用分类明细">×</button>
</div>
<div class="pd-expense-dialog__body">
<table class="pd-expense-dialog__table">
<thead>
<tr>
<th>账单名称</th>
<th class="num">开票金额(万)</th>
<th class="num">不开票金额(万)</th>
<th class="num">总金额(万)</th>
</tr>
</thead>
<tbody id="pdExpenseCategoryDialogRows"></tbody>
</table>
<div class="pd-expense-dialog__empty" id="pdExpenseCategoryDialogEmpty" hidden>当前分类没有账单名称明细。</div>
<div class="pd-expense-dialog__tabs" role="tablist" aria-label="费用分类明细子页面">
<button type="button" class="pd-expense-dialog__tab" id="pdExpenseCategoryPlatformTab" role="tab" aria-selected="true" aria-controls="pdExpenseCategoryPlatformPanel" data-pd-expense-tab="platform">平台汇总</button>
<button type="button" class="pd-expense-dialog__tab" id="pdExpenseCategoryBillTab" role="tab" aria-selected="false" aria-controls="pdExpenseCategoryBillPanel" data-pd-expense-tab="bill">账单名称汇总</button>
</div>
<section id="pdExpenseCategoryPlatformPanel" role="tabpanel" aria-labelledby="pdExpenseCategoryPlatformTab" data-pd-expense-panel="platform">
<table class="pd-expense-dialog__table">
<thead>
<tr>
<th>平台</th>
<th class="num">总金额(万)</th>
</tr>
</thead>
<tbody id="pdExpenseCategoryDialogPlatformRows"></tbody>
</table>
<div class="pd-expense-dialog__empty" id="pdExpenseCategoryDialogPlatformEmpty" hidden>当前分类没有平台明细。</div>
</section>
<section id="pdExpenseCategoryBillPanel" role="tabpanel" aria-labelledby="pdExpenseCategoryBillTab" data-pd-expense-panel="bill" hidden>
<table class="pd-expense-dialog__table">
<thead>
<tr>
<th>账单名称</th>
<th class="num">开票金额(万)</th>
<th class="num">不开票金额(万)</th>
<th class="num">总金额(万)</th>
</tr>
</thead>
<tbody id="pdExpenseCategoryDialogRows"></tbody>
</table>
<div class="pd-expense-dialog__empty" id="pdExpenseCategoryDialogEmpty" hidden>当前分类没有账单名称明细。</div>
</section>
</div>
</dialog>
@endif
@ -736,6 +759,14 @@
var categoryDialog = document.getElementById('pdExpenseCategoryDialog');
var categoryDialogTitle = document.getElementById('pdExpenseCategoryDialogTitle');
var categoryDialogTotal = document.getElementById('pdExpenseCategoryDialogTotal');
var categoryDialogTabs = Array.prototype.slice.call(
categoryDialog.querySelectorAll('[data-pd-expense-tab]')
);
var categoryDialogPanels = Array.prototype.slice.call(
categoryDialog.querySelectorAll('[data-pd-expense-panel]')
);
var categoryDialogPlatformRows = document.getElementById('pdExpenseCategoryDialogPlatformRows');
var categoryDialogPlatformEmpty = document.getElementById('pdExpenseCategoryDialogPlatformEmpty');
var categoryDialogRows = document.getElementById('pdExpenseCategoryDialogRows');
var categoryDialogEmpty = document.getElementById('pdExpenseCategoryDialogEmpty');
var summaryParams = new URLSearchParams(window.location.search);
@ -749,13 +780,38 @@
});
};
function setCategoryDialogTab(name) {
categoryDialogTabs.forEach(function (tab) {
tab.setAttribute(
'aria-selected',
String(tab.getAttribute('data-pd-expense-tab') === name)
);
});
categoryDialogPanels.forEach(function (panel) {
panel.hidden = panel.getAttribute('data-pd-expense-panel') !== name;
});
}
function openCategoryDialog(category) {
setCategoryDialogTab('platform');
categoryDialogTitle.textContent = String(category.memo || '费用分类') + ' · 费用明细';
categoryDialogTotal.textContent = formatWan(
category.net === undefined
? Number(category.invoice_net || 0) + Number(category.non_invoice_net || 0)
: category.net
);
categoryDialogPlatformRows.replaceChildren();
(category.platforms || []).forEach(function (platform) {
var tr = document.createElement('tr');
[platform.platform, formatWan(platform.net)].forEach(function (value, index) {
var cell = document.createElement('td');
cell.textContent = value;
if (index > 0) cell.className = 'num';
tr.appendChild(cell);
});
categoryDialogPlatformRows.appendChild(tr);
});
categoryDialogPlatformEmpty.hidden = Number((category.platforms || []).length) > 0;
categoryDialogRows.replaceChildren();
(category.items || []).forEach(function (item) {
var tr = document.createElement('tr');
@ -789,6 +845,11 @@
}
document.getElementById('pdExpenseCategoryDialogClose').addEventListener('click', closeCategoryDialog);
categoryDialogTabs.forEach(function (tab) {
tab.addEventListener('click', function () {
setCategoryDialogTab(tab.getAttribute('data-pd-expense-tab'));
});
});
categoryDialog.addEventListener('click', function (event) {
if (event.target === categoryDialog) closeCategoryDialog();
});

View File

@ -21,6 +21,9 @@ class ExpenseDetailsDrilldownPageTest extends FeatureTestCase
->assertSee('/workspace/financial-operations/expense-details/item-summary', false)
->assertSee('id="pdItemSummary"', false)
->assertSee('id="pdExpenseCategoryDialog"', false)
->assertSee('id="pdExpenseCategoryPlatformTab"', false)
->assertSee('id="pdExpenseCategoryBillTab"', false)
->assertSee('id="pdExpenseCategoryDialogPlatformRows"', false)
->assertDontSee('data-col="record_code"', false)
->assertDontSee('data-col="bill_item"', false);
@ -42,7 +45,17 @@ class ExpenseDetailsDrilldownPageTest extends FeatureTestCase
], $columns[1]);
$this->assertStringContainsString('pd-item-summary__open', $template);
$this->assertStringContainsString('openCategoryDialog(category)', $template);
$this->assertStringContainsString('category.platforms', $template);
$this->assertStringContainsString("setCategoryDialogTab('platform')", $template);
$this->assertStringContainsString('pd-expense-dialog__table', $template);
$this->assertSame(1, preg_match(
'/data-pd-expense-panel="platform".*?<\/section>/s',
$template,
$platformPanel,
));
$this->assertStringContainsString('<th>平台</th>', $platformPanel[0]);
$this->assertStringContainsString('总金额(万)', $platformPanel[0]);
$this->assertStringNotContainsString('账单名称', $platformPanel[0]);
$this->assertStringNotContainsString('pd-item-summary__toggle', $template);
$this->assertStringNotContainsString('pd-item-summary__drill', $template);
$this->assertStringContainsString(

View File

@ -35,10 +35,10 @@ class BillingDetailsExpenseFilterTest extends TestCase
$totals = [];
$accumulate = new ReflectionMethod(BillingDetailsPageData::class, 'accumulateLedgerMemoTotal');
$accumulate->setAccessible(true);
$accumulate->invokeArgs($data, [&$totals, '技术服务费', 'wx天猫佣金', -90.0, 'invoice']);
$accumulate->invokeArgs($data, [&$totals, '技术服务费', '基础软件服务费', -10.0, 'non_invoice']);
$accumulate->invokeArgs($data, [&$totals, '推广费用', '代扣返点积分', -5.0, 'invoice']);
$accumulate->invokeArgs($data, [&$totals, '', '', -0.5, 'non_invoice']);
$accumulate->invokeArgs($data, [&$totals, '技术服务费', '天猫', 'wx天猫佣金', -90.0, 'invoice']);
$accumulate->invokeArgs($data, [&$totals, '技术服务费', '支付宝', '基础软件服务费', -10.0, 'non_invoice']);
$accumulate->invokeArgs($data, [&$totals, '推广费用', '抖音', '代扣返点积分', -5.0, 'invoice']);
$accumulate->invokeArgs($data, [&$totals, '', '拼多多', '', -0.5, 'non_invoice']);
$sorted = new ReflectionMethod(BillingDetailsPageData::class, 'sortedLedgerMemoTotals');
$sorted->setAccessible(true);
@ -50,6 +50,10 @@ class BillingDetailsExpenseFilterTest extends TestCase
'invoice_net' => -90.0,
'non_invoice_net' => -10.0,
'net' => -100.0,
'platforms' => [
['platform' => '天猫', 'invoice_net' => -90.0, 'non_invoice_net' => 0.0, 'net' => -90.0],
['platform' => '支付宝', 'invoice_net' => 0.0, 'non_invoice_net' => -10.0, 'net' => -10.0],
],
'items' => [
['bill_item' => '天猫佣金', 'invoice_net' => -90.0, 'non_invoice_net' => 0.0, 'net' => -90.0],
['bill_item' => '基础软件服务费', 'invoice_net' => 0.0, 'non_invoice_net' => -10.0, 'net' => -10.0],
@ -60,6 +64,9 @@ class BillingDetailsExpenseFilterTest extends TestCase
'invoice_net' => -5.0,
'non_invoice_net' => 0.0,
'net' => -5.0,
'platforms' => [
['platform' => '抖音', 'invoice_net' => -5.0, 'non_invoice_net' => 0.0, 'net' => -5.0],
],
'items' => [
['bill_item' => '代扣返点积分', 'invoice_net' => -5.0, 'non_invoice_net' => 0.0, 'net' => -5.0],
],
@ -69,6 +76,9 @@ class BillingDetailsExpenseFilterTest extends TestCase
'invoice_net' => 0.0,
'non_invoice_net' => -0.5,
'net' => -0.5,
'platforms' => [
['platform' => '拼多多', 'invoice_net' => 0.0, 'non_invoice_net' => -0.5, 'net' => -0.5],
],
'items' => [
['bill_item' => '未填写账单项目', 'invoice_net' => 0.0, 'non_invoice_net' => -0.5, 'net' => -0.5],
],