diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index ea4535b1..9a7517bb 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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']); } diff --git a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php index 5356e0ac..e77ff7aa 100644 --- a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php @@ -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(), ]; } diff --git a/resources/views/business/pages/partials/installation-fee-summary.blade.php b/resources/views/business/pages/partials/installation-fee-summary.blade.php index 65440a1a..f0d5ed38 100644 --- a/resources/views/business/pages/partials/installation-fee-summary.blade.php +++ b/resources/views/business/pages/partials/installation-fee-summary.blade.php @@ -1,47 +1,21 @@ - - - 安装费金额 - 按客服审核时间汇总安装明细的结算明细金额;收款渠道为空归为包安装,有值归为不包安装。 - - 请选择完整的客服审核起止日期 - - 安装费金额 - 0.00万 - 查看明细 → + + 安装费金额 + 请选择完整的客服审核起止日期 + + + 0.00 + 万元 + — + 较上月 + + + 包安装 0.00(0%) + | + 不包安装 0.00(0%) + @@ -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; diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index e30421c0..9b7f581e 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -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 diff --git a/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php index 77a379ba..7716bb9c 100644 --- a/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php @@ -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