费用分类弹窗增加平台汇总
费用分类弹窗增加平台汇总
This commit is contained in:
parent
98436bfd0a
commit
c9f5e358c4
@ -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 / 抖音账单页仓储同一口径)。
|
||||
*
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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],
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user