feat: 重做安装费金额汇总卡 #603

Merged
qiuxiang merged 1 commits from feature/qiuxiang-installation-fee-card-visual into main 2026-10-26 16:01:25 +08:00
5 changed files with 329 additions and 84 deletions

View File

@ -1116,6 +1116,10 @@ class Pages extends Controller
return response()->json([
'total' => $payload['total'],
'rows' => $payload['rows'],
'previous_total' => $payload['previous_total'] ?? null,
'previous_from' => $payload['previous_from'] ?? null,
'previous_to' => $payload['previous_to'] ?? null,
'change_rate' => $payload['change_rate'] ?? null,
], 200, ['Cache-Control' => 'private, no-store']);
}

View File

@ -136,29 +136,32 @@ class InstallationDetailMirrorRepository
$items = $this->datasets->get(self::ITEM_DATASET);
$connection = $this->mirror->connection();
if (! $this->ready($dataset, $items, $connection->getName())) {
return ['ready' => false, 'total' => 0.0, 'rows' => []];
return [
'ready' => false,
'total' => 0.0,
'rows' => [],
'previous_total' => null,
'previous_from' => null,
'previous_to' => null,
'change_rate' => null,
];
}
$from = trim((string) ($filters['customer_review_from'] ?? ''));
$to = trim((string) ($filters['customer_review_to'] ?? ''));
if ($from === '' || $to === '') {
return ['ready' => true, 'total' => 0.0, 'rows' => []];
return [
'ready' => true,
'total' => 0.0,
'rows' => [],
'previous_total' => null,
'previous_from' => null,
'previous_to' => null,
'change_rate' => null,
];
}
$latest = $connection->table($dataset->targetTable())
->selectRaw('MAX(Id) AS id')
->groupBy('SourceGuid');
$main = $connection->table($dataset->targetTable() . ' AS v')
->joinSub($latest, 'latest_install', static fn ($join) => $join->on('latest_install.id', '=', 'v.Id'))
->select(['v.SourceGuid', 'v.shop_name', 'v.installCompanyName', 'v.costWay']);
$this->applyShipmentCutoff($main);
$query = $connection->table($items->targetTable() . ' AS i')
->joinSub($main, 'install_main', static fn ($join) =>
$join->on('install_main.SourceGuid', '=', 'i.guid'))
->whereNotNull('i.kfshTime')
->where('i.kfshTime', '>=', $from)
->where('i.kfshTime', '<', \Illuminate\Support\Carbon::parse($to)->addDay()->toDateString());
$query = $this->feeDetailsQuery($connection, $dataset, $items, $from, $to);
$platformSql = "CASE"
. " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '%阿里巴巴%'"
@ -202,10 +205,80 @@ class InstallationDetailMirrorRepository
->values()
->all();
$total = round(array_sum(array_column($rows, 'amount')), 2);
$previousRange = $this->previousMonthRange($from, $to);
$previousTotal = null;
$changeRate = null;
if ($previousRange !== null) {
$previousQuery = $this->feeDetailsQuery(
$connection,
$dataset,
$items,
$previousRange['from'],
$previousRange['to'],
);
$previousTotal = round((float) ($previousQuery
->selectRaw('COALESCE(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 0) AS total')
->first()->total ?? 0), 2);
if ($previousTotal > 0) {
$changeRate = round((($total - $previousTotal) / $previousTotal) * 100, 1);
}
}
return [
'ready' => true,
'total' => round(array_sum(array_column($rows, 'amount')), 2),
'total' => $total,
'rows' => $rows,
'previous_total' => $previousTotal,
'previous_from' => $previousRange['from'] ?? null,
'previous_to' => $previousRange['to'] ?? null,
'change_rate' => $changeRate,
];
}
private function feeDetailsQuery(
$connection,
$dataset,
$items,
string $from,
string $to,
) {
$latest = $connection->table($dataset->targetTable())
->selectRaw('MAX(Id) AS id')
->groupBy('SourceGuid');
$main = $connection->table($dataset->targetTable() . ' AS v')
->joinSub($latest, 'latest_install', static fn ($join) => $join->on('latest_install.id', '=', 'v.Id'))
->select(['v.SourceGuid', 'v.shop_name', 'v.installCompanyName', 'v.costWay']);
$this->applyShipmentCutoff($main);
return $connection->table($items->targetTable() . ' AS i')
->joinSub($main, 'install_main', static fn ($join) =>
$join->on('install_main.SourceGuid', '=', 'i.guid'))
->whereNotNull('i.kfshTime')
->where('i.kfshTime', '>=', $from)
->where('i.kfshTime', '<', \Illuminate\Support\Carbon::parse($to)->addDay()->toDateString());
}
/** @return array{from:string,to:string}|null */
private function previousMonthRange(string $from, string $to): ?array
{
$start = \Illuminate\Support\Carbon::parse($from)->startOfDay();
$end = \Illuminate\Support\Carbon::parse($to)->startOfDay();
if ($end->lt($start)) {
return null;
}
if ($start->day === 1 && $end->isSameDay($end->copy()->endOfMonth())) {
$previousStart = $start->copy()->subMonthNoOverflow()->startOfMonth();
$previousEnd = $previousStart->copy()->endOfMonth();
} else {
$previousStart = $start->copy()->subMonthNoOverflow();
$previousEnd = $end->copy()->subMonthNoOverflow();
}
return [
'from' => $previousStart->toDateString(),
'to' => $previousEnd->toDateString(),
];
}

View File

@ -1,47 +1,21 @@
<style>
.installation-fee-board {
margin: 1rem 1.5rem;
padding: 1rem;
border: 1px solid #d8d2c7;
border-radius: 0.75rem;
background: #fcfbf8;
}
.installation-fee-board__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.installation-fee-board__head strong {
display: block;
color: #171717;
font-size: 0.8125rem;
}
.installation-fee-board__head span {
display: block;
margin-top: 0.2rem;
color: #595959;
font-size: 0.6875rem;
line-height: 1.45;
}
.installation-fee-card {
display: flex;
width: min(100%, 15rem);
min-height: 6.5rem;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 0.45rem;
padding: 0.8rem 0.9rem;
position: relative;
display: block;
width: min(100%, 52rem);
min-height: 9.25rem;
padding: 0.95rem 1.15rem 0.8rem 1.25rem;
border: 1px solid #d8d2c7;
border-radius: 0.5rem;
border-left: 3px solid #006395;
border-radius: 0.75rem;
background: #ffffff;
color: #171717;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
font: inherit;
text-align: left;
cursor: pointer;
}
@ -49,6 +23,7 @@
.installation-fee-card:hover,
.installation-fee-card:focus-visible {
border-color: #b9954f;
border-left-color: #006395;
background: #fffdf8;
outline: none;
}
@ -57,32 +32,125 @@
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.installation-fee-card__label {
color: #595959;
.installation-fee-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.installation-fee-card__title {
flex: 0 0 auto;
color: #171717;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.35;
}
.installation-fee-status {
max-width: 31rem;
color: #595959;
font-size: 0.6875rem;
line-height: 1.45;
text-align: right;
}
.installation-fee-status.is-error {
color: #b80000;
}
.installation-fee-card__main {
display: flex;
align-items: baseline;
gap: 0.35rem;
min-width: 0;
margin: 0.35rem 0 0.58rem;
}
.installation-fee-card__value {
color: #171717;
font-size: 1.25rem;
font-size: 2.625rem;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.035em;
font-variant-numeric: tabular-nums;
}
.installation-fee-card__value small {
margin-left: 0.15rem;
.installation-fee-card__unit {
color: #595959;
font-size: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
}
.installation-fee-card__action {
color: #856632;
font-size: 0.6875rem;
.installation-fee-card__trend {
margin-left: 0.4rem;
color: #595959;
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.installation-fee-status {
.installation-fee-card__trend.is-up {
color: #935f07;
}
.installation-fee-card__trend.is-down {
color: #53793e;
}
.installation-fee-card__trend-note {
color: #595959;
font-size: 0.6875rem;
font-weight: 400;
}
.installation-fee-card__split {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
padding-top: 0.55rem;
border-top: 1px solid #e5e0d7;
color: #171717;
font-size: 0.75rem;
line-height: 1.45;
font-variant-numeric: tabular-nums;
}
.installation-fee-card__split strong {
font-weight: 600;
}
.installation-fee-card__split-percent {
margin-left: 0.1rem;
color: #595959;
font-weight: 400;
}
.installation-fee-card__split-sep {
color: #d8d2c7;
font-weight: 400;
}
@media (max-width: 720px) {
.installation-fee-card {
min-height: 0;
padding: 0.9rem 0.95rem 0.8rem 1rem;
}
.installation-fee-card__top {
flex-direction: column;
gap: 0.25rem;
}
.installation-fee-status {
max-width: none;
text-align: left;
}
.installation-fee-card__main {
flex-wrap: wrap;
}
.installation-fee-card__value {
font-size: 2.625rem;
}
}
.installation-fee-dialog {
@ -283,23 +351,29 @@
</style>
<section class="installation-fee-board" aria-label="安装费金额汇总">
<div class="installation-fee-board__head">
<div>
<strong>安装费金额</strong>
<span>按客服审核时间汇总安装明细的结算明细金额;收款渠道为空归为包安装,有值归为不包安装。</span>
</div>
<span class="installation-fee-status" data-installation-fee-status>请选择完整的客服审核起止日期</span>
</div>
<button
type="button"
class="installation-fee-card"
data-installation-fee-open
data-url="{{ route('business.installation-details.summary') }}"
aria-label="查看安装费金额明细"
title="查看安装费金额明细"
>
<span class="installation-fee-card__label">安装费金额</span>
<strong class="installation-fee-card__value"><span data-installation-fee-total>0.00</span><small>万</small></strong>
<span class="installation-fee-card__action">查看明细 →</span>
<span class="installation-fee-card__top">
<strong class="installation-fee-card__title">安装费金额</strong>
<span class="installation-fee-status" data-installation-fee-status aria-live="polite">请选择完整的客服审核起止日期</span>
</span>
<span class="installation-fee-card__main">
<strong class="installation-fee-card__value" data-installation-fee-total>0.00</strong>
<span class="installation-fee-card__unit">万元</span>
<span class="installation-fee-card__trend" data-installation-fee-trend>—</span>
<span class="installation-fee-card__trend-note">较上月</span>
</span>
<span class="installation-fee-card__split">
<span>包安装 <strong data-installation-fee-package>0.00</strong><span class="installation-fee-card__split-percent" data-installation-fee-package-percent>(0%)</span></span>
<span class="installation-fee-card__split-sep" aria-hidden="true">|</span>
<span>不包安装 <strong data-installation-fee-non-package>0.00</strong><span class="installation-fee-card__split-percent" data-installation-fee-non-package-percent>(0%)</span></span>
</span>
</button>
</section>
@ -384,7 +458,12 @@
var fromInput = form.elements.namedItem('date_from');
var toInput = form.elements.namedItem('date_to');
var cardTotal = card.querySelector('[data-installation-fee-total]');
var status = document.querySelector('[data-installation-fee-status]');
var status = card.querySelector('[data-installation-fee-status]');
var trend = card.querySelector('[data-installation-fee-trend]');
var packageAmount = card.querySelector('[data-installation-fee-package]');
var packagePercent = card.querySelector('[data-installation-fee-package-percent]');
var nonPackageAmount = card.querySelector('[data-installation-fee-non-package]');
var nonPackagePercent = card.querySelector('[data-installation-fee-non-package-percent]');
var subtitle = dialog.querySelector('[data-installation-fee-subtitle]');
var currentTotal = dialog.querySelector('[data-installation-fee-current-total]');
var platformFilter = dialog.querySelector('[data-installation-fee-platform]');
@ -414,6 +493,78 @@
});
}
function amount(value) {
return Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function captionRange(value) {
var matchFrom = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.from);
var matchTo = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.to);
if (!matchFrom || !matchTo) {
return value.from + ' 至 ' + value.to;
}
var end = matchFrom[1] === matchTo[1]
? matchTo[2] + '-' + matchTo[3]
: value.to;
return value.from + ' 至 ' + end;
}
function setTrend(changeRate) {
trend.className = 'installation-fee-card__trend';
if (changeRate === null || changeRate === undefined || !Number.isFinite(Number(changeRate))) {
trend.textContent = '—';
return;
}
var rate = Number(changeRate);
if (rate > 0) {
trend.textContent = '↑' + Math.abs(rate).toLocaleString('zh-CN', { maximumFractionDigits: 1 }) + '%';
trend.classList.add('is-up');
} else if (rate < 0) {
trend.textContent = '↓' + Math.abs(rate).toLocaleString('zh-CN', { maximumFractionDigits: 1 }) + '%';
trend.classList.add('is-down');
} else {
trend.textContent = '持平';
}
}
function setCardValue(total, rows, value, data) {
var packageTotal = 0;
var nonPackageTotal = 0;
rows.forEach(function (row) {
if (row.payment_channel === '包安装') {
packageTotal += Number(row.amount || 0);
} else {
nonPackageTotal += Number(row.amount || 0);
}
});
var packageRate = total > 0 ? Math.round((packageTotal / total) * 100) : 0;
var nonPackageRate = total > 0 ? 100 - packageRate : 0;
cardTotal.textContent = wan(total);
packageAmount.textContent = amount(packageTotal);
nonPackageAmount.textContent = amount(nonPackageTotal);
packagePercent.textContent = '(' + packageRate + '%)';
nonPackagePercent.textContent = '(' + nonPackageRate + '%)';
setTrend(data.change_rate);
status.classList.remove('is-error');
status.textContent = '按客服审核时间 · ' + captionRange(value) + ' · ' + rows.length + ' 单';
}
function resetCard(message) {
cardTotal.textContent = '0.00';
packageAmount.textContent = '0.00';
nonPackageAmount.textContent = '0.00';
packagePercent.textContent = '(0%)';
nonPackagePercent.textContent = '(0%)';
setTrend(null);
status.textContent = message;
status.classList.toggle('is-error', message.indexOf('失败') >= 0 || message.indexOf('不能') >= 0);
}
function range() {
return {
from: String(fromInput?.value || ''),
@ -542,14 +693,12 @@
function refresh(force) {
var value = range();
if (!value.from || !value.to) {
cardTotal.textContent = '0.00';
status.textContent = '请选择完整的客服审核起止日期';
resetCard('请选择完整的客服审核起止日期');
resetDialog('请选择完整的客服审核起止日期');
return Promise.resolve(false);
}
if (value.from > value.to) {
cardTotal.textContent = '0.00';
status.textContent = '客服审核结束日期不能早于开始日期';
resetCard('客服审核结束日期不能早于开始日期');
resetDialog('客服审核结束日期不能早于开始日期');
return Promise.resolve(false);
}
@ -561,7 +710,8 @@
if (controller) { controller.abort(); }
controller = new AbortController();
dialog.setAttribute('aria-busy', 'true');
status.textContent = '正在汇总安装费…';
status.classList.remove('is-error');
status.textContent = '按客服审核时间 · 正在汇总…';
cardTotal.textContent = '…';
var url = new URL(card.dataset.url, window.location.origin);
url.searchParams.set('customer_review_from', value.from);
@ -581,8 +731,7 @@
}).then(function (data) {
allRows = Array.isArray(data.rows) ? data.rows : [];
loadedSignature = currentSignature;
cardTotal.textContent = wan(data.total);
status.textContent = value.from + ' 至 ' + value.to + ' · ' + allRows.length + ' 条汇总';
setCardValue(Number(data.total || 0), allRows, value, data);
subtitle.textContent = value.from + ' 至 ' + value.to + ' · 按客服审核时间汇总';
fillFilter(platformFilter, '全部平台', distinct('platform'));
fillFilter(storeFilter, '全部店铺', distinct('store_name'));
@ -593,8 +742,7 @@
return true;
}).catch(function (error) {
if (error && error.name === 'AbortError') { return false; }
cardTotal.textContent = '0.00';
status.textContent = error.message || '安装费汇总读取失败';
resetCard(error.message || '安装费汇总读取失败');
resetDialog(error.message || '安装费汇总读取失败,请稍后重试。');
dialog.removeAttribute('aria-busy');
return false;

View File

@ -279,7 +279,16 @@ class ManagementCrudTest extends FeatureTestCase
->assertSee('data-installation-fee-open', false)
->assertSee('id="installation-fee-dialog"', false)
->assertSeeText('安装费金额')
->assertSeeText('按客服审核时间汇总安装明细的结算明细金额')
->assertSeeText('较上月')
->assertSeeText('包安装')
->assertSeeText('不包安装')
->assertSee('data-installation-fee-trend', false)
->assertSee('data-installation-fee-package', false)
->assertSee('data-installation-fee-non-package', false)
->assertSee('border-left: 3px solid #006395', false)
->assertSee('font-size: 2.625rem', false)
->assertSee('function captionRange(value)', false)
->assertSee('function setCardValue(total, rows, value, data)', false)
->assertSeeText('平台')
->assertSeeText('店铺名称')
->assertSeeText('安装公司')
@ -406,6 +415,10 @@ class ManagementCrudTest extends FeatureTestCase
['platform' => '淘系', 'store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '包安装', 'amount' => 1250.50],
['platform' => '京东', 'store_name' => '店铺B', 'installation_company' => '鲁班到家', 'payment_channel' => '不包安装', 'amount' => 750.00],
],
'previous_total' => 1600.40,
'previous_from' => '2026-08-01',
'previous_to' => '2026-08-31',
'change_rate' => 25.0,
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
@ -419,7 +432,9 @@ class ManagementCrudTest extends FeatureTestCase
->assertJsonPath('rows.0.platform', '淘系')
->assertJsonPath('rows.0.store_name', '店铺A')
->assertJsonPath('rows.0.payment_channel', '包安装')
->assertJsonPath('rows.1.payment_channel', '不包安装');
->assertJsonPath('rows.1.payment_channel', '不包安装')
->assertJsonPath('previous_total', 1600.4)
->assertJsonPath('change_rate', 25.0);
}
public function testInstallationSummaryRequiresCompleteCustomerReviewRange(): void

View File

@ -140,6 +140,7 @@ class InstallationDetailMirrorRepositoryTest extends TestCase
DB::connection('business_mirror')->table('fixture_install')->where('Id',3)->update(['installCompanyName'=>'鲁班到家','costWay'=>'银行转账']);
DB::connection('business_mirror')->table('fixture_install')->where('Id',4)->update(['installCompanyName'=>'万师傅']);
DB::connection('business_mirror')->table('fixture_items')->insert([
['Id'=>19,'guid'=>'A','shopproid'=>'P0','Sourceno'=>'A-0','kfshTime'=>'2026-08-15 10:00:00','settleItemAmount'=>90.00,'acceptItemAmount'=>80.00],
['Id'=>20,'guid'=>'A','shopproid'=>'P1','Sourceno'=>'A-1','kfshTime'=>'2026-09-01 09:00:00','settleItemAmount'=>60.10,'acceptItemAmount'=>40.00],
['Id'=>21,'guid'=>'A','shopproid'=>'P2','Sourceno'=>'A-1','kfshTime'=>'2026-09-30 18:00:00','settleItemAmount'=>39.90,'acceptItemAmount'=>0.00],
['Id'=>22,'guid'=>'B','shopproid'=>'P3','Sourceno'=>'B-1','kfshTime'=>'2026-09-15 10:00:00','settleItemAmount'=>80.00,'acceptItemAmount'=>70.00],
@ -159,6 +160,10 @@ class InstallationDetailMirrorRepositoryTest extends TestCase
['platform'=>'其他','store_name'=>'其他店','installation_company'=>'鲁班到家','payment_channel'=>'不包安装','amount'=>80.0],
],$result['rows']);
$this->assertSame(180.0,$result['total']);
$this->assertSame(90.0,$result['previous_total']);
$this->assertSame('2026-08-01',$result['previous_from']);
$this->assertSame('2026-08-31',$result['previous_to']);
$this->assertSame(100.0,$result['change_rate']);
}
public function test_fee_details_classifies_platform_by_store_name_rules(): void