feat: 将附加费报价和结算整合为同一入口的两个页签

(cherry picked from commit a83347aef3c9069df2f521df9b4cbe1096255a33)
This commit is contained in:
zhoushengchen 2026-09-23 16:41:22 +08:00
parent d9646b4ac9
commit 28f99acebc
6 changed files with 401 additions and 13 deletions

View File

@ -324,6 +324,15 @@ class Pages extends Controller
abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。');
// Keep the old direct URL working after settlement becomes a tab.
if ($section === 'courier-payables' && $page === 'courier-surcharge-settlement') {
return redirect()->route('business.pages.show', array_merge(request()->query(), [
'section' => $section,
'page' => 'courier-surcharge',
'tab' => 'settlement',
]));
}
$pages = collect($sectionConfig['pages'] ?? []);
if ($pages->isEmpty()) {
@ -390,6 +399,18 @@ class Pages extends Controller
}
if ($tab === null) {
if ($page === 'courier-surcharge') {
$activeTab = $tabs->first();
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
$activeTab,
$this->resolveContent($page, $activeTab['slug']),
));
}
return $this->redirectToPage($sectionConfig, $pageConfig);
}
@ -2645,7 +2666,7 @@ class Pages extends Controller
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'product-costs' => $this->contentForProductCosts(),
'financial-analysis' => $this->contentForFinancialAnalysis(),
'courier-surcharge' => $this->contentForCourierSurcharge(),
'courier-surcharge' => $this->contentForCourierSurcharge($tab),
'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page),
'shipping-data' => $this->contentForShippingData(),
'return-pickup-management' => $this->contentForReturnPickupManagement(),
@ -3644,24 +3665,26 @@ class Pages extends Controller
];
}
protected function contentForCourierSurcharge(): array
protected function contentForCourierSurcharge(?string $tab = null): array
{
$clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length);
$quoteInput = $tab === 'settlement' ? [] : request()->query();
$filters = [
'keyword' => $clean(request('keyword', ''), 40),
'courier' => $clean(request('courier', ''), 40),
'type' => $clean(request('type', ''), 40),
'mode' => $clean(request('mode', ''), 40),
'scope' => $clean(request('scope', ''), 20),
'reconciler' => $clean(request('reconciler', ''), 40),
'supplier' => $clean(request('supplier', ''), 191),
'status' => $clean(request('status', ''), 20),
'keyword' => $clean($quoteInput['keyword'] ?? '', 40),
'courier' => $clean($quoteInput['courier'] ?? '', 40),
'type' => $clean($quoteInput['type'] ?? '', 40),
'mode' => $clean($quoteInput['mode'] ?? '', 40),
'scope' => $clean($quoteInput['scope'] ?? '', 20),
'reconciler' => $clean($quoteInput['reconciler'] ?? '', 40),
'supplier' => $clean($quoteInput['supplier'] ?? '', 191),
'status' => $clean($quoteInput['status'] ?? '', 20),
];
$data = $this->courierSurchargeQuotes->pageData($filters);
$auditReady = $this->courierSurchargeAudits->ready();
return [
'view' => 'business.pages.courier-payables.courier-surcharge',
'view' => 'business.pages.courier-payables.courier-surcharge-workspace',
'courierSurchargeActiveTab' => $tab ?? 'quotes',
'courierSurchargeRows' => $data['rows'],
'courierSurchargeFilters' => $filters,
'courierSurchargeSummary' => $data['summary'],

View File

@ -712,7 +712,14 @@ $navigation = [
['slug' => 'compensation-details', 'title' => '赔偿明细'],
['slug' => 'advance-details', 'title' => '借支明细'],
['slug' => 'other-adjustments', 'title' => '其他扣款补款'],
['slug' => 'courier-surcharge', 'title' => '附加费报价维护'],
[
'slug' => 'courier-surcharge',
'title' => '附加费报价维护',
'tabs' => [
['slug' => 'quotes', 'title' => '附加费报价'],
['slug' => 'settlement', 'title' => '附加费结算'],
],
],
['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'],
['slug' => 'courier-payables-report', 'title' => '快递应付表'],
['slug' => 'freight-difference', 'title' => '运费差'],

View File

@ -0,0 +1,201 @@
@php
$rows = $courierSurchargeSettlementRows ?? collect();
$columns = [
['key' => 'courier_name', 'label' => '快递名称', 'fixed' => 'true'],
['key' => 'waybill', 'label' => '单号'],
['key' => 'platform_product_id', 'label' => '平台商品ID'],
['key' => 'product_code', 'label' => '商品编码'],
['key' => 'shop_name', 'label' => '店铺名称'],
['key' => 'online_order_no', 'label' => '线上订单号'],
['key' => 'length', 'label' => '长'],
['key' => 'width', 'label' => '宽'],
['key' => 'height', 'label' => '高'],
['key' => 'volume', 'label' => '体积'],
['key' => 'inspection_type', 'label' => '查件类型'],
['key' => 'inspection_original', 'label' => '查件原件'],
['key' => 'return_waybill_no', 'label' => '退回单号'],
['key' => 'settlement_weight', 'label' => '结算重量'],
['key' => 'settlement_amount', 'label' => '结算金额', 'fixed' => 'end'],
];
$filterValue = static fn (string $key): string => trim((string) request()->query($key, ''));
$displayValue = static function (mixed $value, string $key): string {
if ($value === null || $value === '') {
return '—';
}
if ($key === 'settlement_amount' && is_numeric($value)) {
return number_format((float) $value, 2, '.', '');
}
return (string) $value;
};
@endphp
<section class="surcharge-settlement-page" aria-label="附加费结算">
<style>
.surcharge-settlement-page {
--settlement-ink: var(--vip-content-ink, #171717);
--settlement-muted: var(--vip-content-muted, #595959);
--settlement-accent: var(--vip-content-accent-dark, #856632);
--settlement-soft: var(--vip-content-accent-soft, #f7f0de);
--settlement-border: var(--vip-border, #d8d2c7);
display: grid;
gap: 1rem;
min-width: 0;
color: var(--settlement-ink);
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
font-size: .8125rem;
}
.surcharge-settlement-page *,
.surcharge-settlement-page *::before,
.surcharge-settlement-page *::after { box-sizing: border-box; }
.surcharge-settlement-page > * { min-width: 0; }
.settlement-notice {
display: flex;
align-items: flex-start;
gap: .65rem;
padding: .75rem 1rem;
border: 1px solid #d7c39d;
border-radius: .65rem;
background: #fffaf0;
color: #6a5129;
line-height: 1.55;
}
.settlement-notice .material-icons-outlined { color: var(--settlement-accent); font-size: 1.15rem; }
.settlement-notice strong { color: var(--settlement-ink); }
.settlement-summary {
display: grid;
grid-template-columns: 1.35fr repeat(3, minmax(8rem, 1fr));
overflow: hidden;
border: 1px solid var(--settlement-border);
border-radius: .75rem;
background: #fff;
}
.settlement-summary__intro,
.settlement-summary__metric { min-width: 0; padding: 1rem 1.15rem; }
.settlement-summary__intro { display: flex; align-items: center; gap: .75rem; }
.settlement-summary__icon { display: grid; flex: none; width: 2.4rem; height: 2.4rem; place-items: center; border-radius: .6rem; background: var(--settlement-soft); color: var(--settlement-accent); }
.settlement-summary__title { font-size: .95rem; font-weight: 700; }
.settlement-summary__subtitle { margin-top: .2rem; color: var(--settlement-muted); font-size: .72rem; }
.settlement-summary__metric { border-left: 1px solid var(--settlement-border); }
.settlement-summary__label { color: var(--settlement-muted); font-size: .72rem; }
.settlement-summary__value { margin-top: .25rem; font-size: 1.1rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.settlement-filter-bar {
display: grid;
grid-template-columns: repeat(4, minmax(10rem, 1fr));
gap: .7rem;
align-items: end;
padding: 1rem 1.15rem;
border: 1px solid var(--settlement-border);
border-radius: .75rem;
background: #fffdf8;
}
.settlement-field { display: grid; gap: .3rem; min-width: 0; }
.settlement-field label { color: var(--settlement-muted); font-size: .72rem; font-weight: 600; }
.settlement-field input,
.settlement-field select {
width: 100%;
height: 2.2rem;
min-width: 0;
padding: 0 .6rem;
border: 1px solid var(--settlement-border);
border-radius: .45rem;
background: #fff;
color: var(--settlement-ink);
font: inherit;
}
.settlement-field input:focus,
.settlement-field select:focus { border-color: var(--settlement-accent); outline: none; box-shadow: 0 0 0 3px rgba(133, 102, 50, .18); }
.settlement-actions { display: flex; flex-wrap: wrap; gap: .45rem; align-items: end; }
.settlement-button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-height: 2.2rem; padding: 0 .75rem; border: 1px solid var(--settlement-border); border-radius: .45rem; background: #fff; color: var(--settlement-ink); font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap; cursor: pointer; }
.settlement-button--primary { border-color: var(--settlement-accent); background: var(--settlement-accent); color: #fff; }
.settlement-button .material-icons-outlined { font-size: 1rem; }
.settlement-table-surface { overflow: visible; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; }
.settlement-table-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: .75rem 1rem; padding: .85rem 1.15rem; border-bottom: 1px solid var(--settlement-border); }
.settlement-table-title { display: flex; align-items: center; gap: .45rem; font-size: .9rem; font-weight: 700; }
.settlement-table-title .material-icons-outlined { color: var(--settlement-accent); font-size: 1.1rem; }
.settlement-table-hint { color: var(--settlement-muted); font-size: .72rem; }
.settlement-table-meta { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .6rem 1rem; padding: .65rem 1.15rem; border-bottom: 1px solid var(--settlement-border); background: #fffdf8; color: var(--settlement-muted); font-size: .72rem; }
.settlement-table-meta strong { color: var(--settlement-ink); }
.settlement-table-scroll { max-height: min(62vh, 42rem); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable both-edges; }
.settlement-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: .75rem; }
.settlement-table th,
.settlement-table td { min-width: 7.5rem; padding: .62rem .7rem; border-right: 1px solid var(--settlement-border); border-bottom: 1px solid var(--settlement-border); text-align: left; vertical-align: top; white-space: nowrap; }
.settlement-table th { background: var(--settlement-soft); font-weight: 700; }
.settlement-table th[data-finance-fixed="true"],
.settlement-table td[data-finance-fixed="true"] { background: #fffdf8; }
.settlement-table th[data-finance-fixed="end"],
.settlement-table td[data-finance-fixed="end"] { background: #fffdf8; }
.settlement-table td { overflow: hidden; text-overflow: ellipsis; }
.settlement-table .settlement-empty td { min-width: 22rem; padding: 3rem 1rem; text-align: center; color: var(--settlement-muted); white-space: normal; }
@media (max-width: 900px) {
.settlement-summary { grid-template-columns: 1fr 1fr; }
.settlement-summary__intro { grid-column: 1 / -1; }
.settlement-summary__metric:nth-child(2) { border-left: 0; }
.settlement-filter-bar { grid-template-columns: repeat(2, minmax(10rem, 1fr)); }
}
@media (max-width: 560px) {
.settlement-summary { grid-template-columns: 1fr; }
.settlement-summary__metric { border-left: 0; border-top: 1px solid var(--settlement-border); }
.settlement-filter-bar { grid-template-columns: 1fr; }
}
</style>
<div class="settlement-notice" role="status">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<div>等待接入结算数据;确认数据来源后,将按附加费报价规则计算。</div>
</div>
<div class="settlement-summary" aria-label="附加费结算概览">
<div class="settlement-summary__intro"><span class="settlement-summary__icon material-icons-outlined" aria-hidden="true">calculate</span><div><div class="settlement-summary__title">附加费结算</div><div class="settlement-summary__subtitle">结算标准读取附加费报价维护</div></div></div>
<div class="settlement-summary__metric"><div class="settlement-summary__label">当前明细</div><div class="settlement-summary__value">{{ $rows->count() }}</div></div>
<div class="settlement-summary__metric"><div class="settlement-summary__label">结算金额</div><div class="settlement-summary__value">待接入</div></div>
<div class="settlement-summary__metric"><div class="settlement-summary__label">数据状态</div><div class="settlement-summary__value">待确认来源</div></div>
</div>
<form class="settlement-filter-bar" method="GET" action="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => 'settlement']) }}" aria-label="附加费结算筛选">
<div class="settlement-field"><label for="settlement-courier">快递名称</label><input id="settlement-courier" type="search" name="courier_name" value="{{ $filterValue('courier_name') }}" placeholder="输入快递名称"></div>
<div class="settlement-field"><label for="settlement-waybill">单号</label><input id="settlement-waybill" type="search" name="waybill" value="{{ $filterValue('waybill') }}" placeholder="输入单号"></div>
<div class="settlement-field"><label for="settlement-shop">店铺名称</label><input id="settlement-shop" type="search" name="shop_name" value="{{ $filterValue('shop_name') }}" placeholder="输入店铺名称"></div>
<div class="settlement-field"><label for="settlement-online-order">线上订单号</label><input id="settlement-online-order" type="search" name="online_order_no" value="{{ $filterValue('online_order_no') }}" placeholder="输入线上订单号"></div>
<div class="settlement-field"><label for="settlement-inspection">查件类型</label><select id="settlement-inspection" name="inspection_type"><option value="">全部查件类型</option></select></div>
<div class="settlement-field"><label for="settlement-status">结算状态</label><select id="settlement-status" name="status"><option value="">全部状态</option><option value="待接入" @selected($filterValue('status') === '待接入')>待接入</option><option value="已计算" @selected($filterValue('status') === '已计算')>已计算</option></select></div>
<div class="settlement-actions"><button class="settlement-button settlement-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="settlement-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => 'settlement']) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a></div>
</form>
<div class="settlement-table-surface" data-finance-table-surface>
<div class="settlement-table-head"><div><div class="settlement-table-title"><span class="material-icons-outlined" aria-hidden="true">table_view</span>附加费结算明细</div><div class="settlement-table-hint">快递名称和结算金额固定显示,其余字段可在“列设置”中调整。</div></div><div class="finance-table-controls" data-finance-controls><span class="settlement-table-hint">列设置按当前用户和本页面保存</span></div></div>
<div class="settlement-table-meta"><span>当前筛选结果:<strong>{{ $rows->count() }}</strong> 条</span><span>报价匹配和金额计算将在确认数据来源后接入</span></div>
<div class="settlement-table-scroll finance-table-scroll-owner">
<table class="settlement-table" data-finance-table data-finance-table-name="courier-surcharge-settlement" data-finance-table-key="{{ auth()->id() }}:{{ company_id() }}:courier-payables:courier-surcharge:settlement:details" aria-label="附加费结算明细">
<thead><tr>
@foreach ($columns as $column)
<th scope="col" data-finance-column-key="{{ $column['key'] }}" data-finance-fixed="{{ $column['fixed'] ?? '' }}" data-export-field="{{ $column['key'] }}">{{ $column['label'] }}</th>
@endforeach
</tr></thead>
<tbody>
@forelse ($rows as $row)
<tr>
@foreach ($columns as $column)
<td data-finance-column-key="{{ $column['key'] }}" data-export-value="{{ $displayValue(data_get($row, $column['key']), $column['key']) }}">{{ $displayValue(data_get($row, $column['key']), $column['key']) }}</td>
@endforeach
</tr>
@empty
<tr class="settlement-empty is-empty"><td colspan="{{ count($columns) }}"><span class="material-icons-outlined" aria-hidden="true">hourglass_empty</span><div>页面已就绪,等待接入附加费结算数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</section>

View File

@ -0,0 +1,63 @@
@php
$surchargeTabUrls = collect(['quotes', 'settlement'])->mapWithKeys(fn ($tab) => [
$tab => route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => $tab]),
])->all();
@endphp
<div data-surcharge-workspace>
<div data-surcharge-panel="quotes" @if ($courierSurchargeActiveTab !== 'quotes') hidden @endif>
@include('business.pages.courier-payables.courier-surcharge')
</div>
<div data-surcharge-panel="settlement" @if ($courierSurchargeActiveTab !== 'settlement') hidden @endif>
@include('business.pages.courier-payables.courier-surcharge-settlement')
</div>
</div>
<script>
(() => {
const root = document.querySelector('[data-surcharge-workspace]');
const content = root.closest('.business-content-section');
const navigation = content.parentElement.querySelector('.business-tab-bar');
const links = [...navigation.querySelectorAll('a')];
const panels = [...root.querySelectorAll('[data-surcharge-panel]')];
const title = document.querySelector('[data-page-title-first] h1');
const documentTitle = document.title;
const initialTitle = @json($courierSurchargeActiveTab === 'settlement' ? '附加费结算' : '附加费报价');
const urls = @json($surchargeTabUrls);
const keyFor = (link) => Object.keys(urls).find(key => new URL(urls[key]).pathname === new URL(link.href).pathname);
const savedUrls = { ...urls };
let active = @json($courierSurchargeActiveTab);
savedUrls[active] = location.href;
function select(key, push = true) {
if (!Object.hasOwn(urls, key)) return;
if (push) savedUrls[active] = location.href;
active = key;
panels.forEach(panel => { panel.hidden = panel.dataset.surchargePanel !== key; });
links.forEach(link => {
const selected = keyFor(link) === key;
link.classList.toggle('is-active', selected);
if (selected) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
});
const label = key === 'settlement' ? '附加费结算' : '附加费报价';
if (title) title.textContent = label;
content.setAttribute('aria-label', label);
document.title = documentTitle.replace(initialTitle, label);
if (push) history.pushState({}, '', savedUrls[key]);
window.dispatchEvent(new Event('resize'));
}
links.forEach(link => link.addEventListener('click', event => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
const key = keyFor(link);
if (!key) return;
event.preventDefault();
if (key !== active) select(key);
}));
window.addEventListener('popstate', () => {
const key = Object.keys(urls).find(key => new URL(urls[key]).pathname === location.pathname) || 'quotes';
select(key, false);
savedUrls[key] = location.href;
});
})();
</script>

View File

@ -31,7 +31,8 @@ class CourierSurchargePageTest extends FeatureTestCase
'page' => 'courier-surcharge',
]))
->assertOk()
->assertSeeText('附加费报价维护')
->assertSeeText('附加费报价')
->assertSee('aria-label="附加费报价维护"', false)
->assertSeeText('正式维护数据')
->assertSeeText('报价规则列表')
->assertSeeText('管理附加费类型')

View File

@ -0,0 +1,93 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Tests\Feature\FeatureTestCase;
class CourierSurchargeSettlementPageTest extends FeatureTestCase
{
public function test_page_renders_requested_settlement_columns_without_fake_rows(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
]))->assertOk()
->assertSee('data-surcharge-panel="quotes" hidden', false)
->assertSeeText('附加费结算')
->assertSeeText('页面已就绪,等待接入附加费结算数据。')
->assertSee('data-finance-table-name="courier-surcharge-settlement"', false)
->assertSee('data-finance-fixed="true"', false)
->assertSee('data-finance-fixed="end"', false)
->assertSeeText('快递名称')
->assertSeeText('平台商品ID')
->assertSeeText('查件原件')
->assertSeeText('退回单号')
->assertSeeText('结算重量')
->assertSeeText('结算金额');
}
public function test_settlement_filters_are_preserved_in_the_form(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
'courier_name' => '测试快递',
'waybill' => '001234',
'shop_name' => '测试店铺',
'online_order_no' => 'SO-001',
'status' => '待接入',
]))->assertOk()
->assertSee('value="测试快递"', false)
->assertSee('value="001234"', false)
->assertSee('value="测试店铺"', false)
->assertSee('value="SO-001"', false)
->assertSee('value="待接入" selected', false)
->assertViewHas('content', fn (array $content) => $content['courierSurchargeFilters']['status'] === '');
}
public function test_quote_page_exposes_quote_and_settlement_tabs(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
]))->assertOk()
->assertSeeText('附加费报价')
->assertSeeText('附加费结算')
->assertSee('data-surcharge-panel="settlement" hidden', false)
->assertSee(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'quotes',
]), false)
->assertSee(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
]), false);
}
public function test_old_settlement_page_url_redirects_to_settlement_tab(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge-settlement',
'waybill' => '0000123456789',
]))->assertRedirect(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
'waybill' => '0000123456789',
]));
}
}