From 447f60344251ed29f21ca6ff964a59997200b9cd Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 2 Sep 2026 12:00:00 +0800 Subject: [PATCH 1/7] feat: add courier surcharge prototype page --- app/Http/Controllers/Business/Pages.php | 101 ++++++++++++++ config/business_navigation.php | 1 + .../courier-surcharge.blade.php | 128 ++++++++++++++++++ .../Business/CourierSurchargePageTest.php | 56 ++++++++ 4 files changed, 286 insertions(+) create mode 100644 resources/views/business/pages/courier-payables/courier-surcharge.blade.php create mode 100644 tests/Feature/Business/CourierSurchargePageTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 8c64d641..891bfc9d 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -447,11 +447,112 @@ class Pages extends Controller 'board-deductions' => $this->contentForBoardDeductions(), 'supplier-payables-report' => $this->contentForSupplierPayablesReport(), 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), + 'courier-surcharge' => $this->contentForCourierSurcharge(), 'platform-promotion-accounts' => app(PlatformPromotionAccountPageData::class)->content($tab), default => null, }; } + /** + * 快递附加费规则落地原型。 + * + * 这里使用脱敏演示数据,先把文档中的结算模式、判定依据和可结状态 + * 以页面形式呈现;真实上游镜像接入应沿用独立的同步任务和只读 Repository。 + */ + protected function contentForCourierSurcharge(): array + { + $rows = collect([ + [ + 'number' => 'SF20260901001', 'courier' => '曹县德邦', 'type' => '超长费', + 'mode' => '一口价判断', 'basis' => '最长边 172 cm > 160 cm,按 8 元/单', + 'amount' => 8.00, 'status' => '可结', 'reason' => '', + 'dimensions' => '172 × 68 × 54 cm', 'weight' => '36 kg', 'volume' => '0.63 m³', + 'returned' => '否', 'source' => '规则配置(演示)', + ], + [ + 'number' => 'SF20260901002', 'courier' => '曹县德邦', 'type' => '改地址', + 'mode' => '聚工单客服核实', 'basis' => '等待聚工单客服核实明细', + 'amount' => 30.00, 'status' => '待核实', 'reason' => '缺少客服核实结果', + 'dimensions' => '120 × 45 × 40 cm', 'weight' => '18 kg', 'volume' => '0.22 m³', + 'returned' => '否', 'source' => '聚工单(待同步)', + ], + [ + 'number' => 'SF20260901003', 'courier' => '常州松宇货运', 'type' => '送货费', + 'mode' => '一口价判断', 'basis' => '体积 8.4 m³ ≤ 10 m³,按 50 元/单', + 'amount' => 50.00, 'status' => '可结', 'reason' => '', + 'dimensions' => '240 × 140 × 250 cm', 'weight' => '420 kg', 'volume' => '8.4 m³', + 'returned' => '否', 'source' => '规则配置(演示)', + ], + [ + 'number' => 'SF20260901004', 'courier' => '霸州百世快运-雅柏', 'type' => '退回费', + 'mode' => '一口价判断', 'basis' => '结算重量 18 kg × 2 元/kg', + 'amount' => 36.00, 'status' => '可结', 'reason' => '', + 'dimensions' => '110 × 50 × 45 cm', 'weight' => '18 kg', 'volume' => '0.25 m³', + 'returned' => '是', 'source' => '规则配置(演示)', + ], + [ + 'number' => 'SF20260901005', 'courier' => '北京韵达-佰硕', 'type' => '异形件', + 'mode' => '一口价判断', 'basis' => '尺寸/重量未命中当月阶梯标准', + 'amount' => 0.00, 'status' => '不可结', 'reason' => '未匹配到有效阶梯价格', + 'dimensions' => '205 × 95 × 88 cm', 'weight' => '72 kg', 'volume' => '1.71 m³', + 'returned' => '否', 'source' => '规则配置(演示)', + ], + [ + 'number' => 'SF20260901006', 'courier' => '菏泽顺帆', 'type' => '发出运费附加', + 'mode' => '附加费等于发出运费', 'basis' => '关联发货记录运费 88 元', + 'amount' => 88.00, 'status' => '可结', 'reason' => '', + 'dimensions' => '100 × 40 × 35 cm', 'weight' => '15 kg', 'volume' => '0.14 m³', + 'returned' => '否', 'source' => '发货运费(演示)', + ], + ]); + + $clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length); + $filters = [ + 'keyword' => $clean(request('keyword', ''), 40), + 'courier' => $clean(request('courier', ''), 40), + 'type' => $clean(request('type', ''), 40), + 'mode' => $clean(request('mode', ''), 40), + 'status' => $clean(request('status', ''), 20), + ]; + $filtered = $rows->filter(static function (array $row) use ($filters): bool { + foreach (['courier', 'type', 'mode', 'status'] as $key) { + if ($filters[$key] !== '' && $row[$key] !== $filters[$key]) { + return false; + } + } + if ($filters['keyword'] !== '' + && ! str_contains($row['number'], $filters['keyword']) + && ! str_contains($row['basis'], $filters['keyword'])) { + return false; + } + + return true; + })->values(); + + return [ + 'view' => 'business.pages.courier-payables.courier-surcharge', + 'courierSurchargeRows' => $filtered, + 'courierSurchargeFilters' => $filters, + 'courierSurchargeSummary' => [ + 'total' => $filtered->count(), + 'ready' => $filtered->where('status', '可结')->count(), + 'pending' => $filtered->where('status', '待核实')->count(), + 'blocked' => $filtered->where('status', '不可结')->count(), + 'amount' => $filtered->sum('amount'), + ], + 'courierSurchargeOptions' => [ + 'couriers' => $rows->pluck('courier')->unique()->sort()->values(), + // 文档定义的十类附加费全部进入筛选器,即使当前演示样例暂未覆盖每一类。 + 'types' => collect([ + '退回费', '拦截费', '改地址', '异形件', '超长费', + '上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加', + ]), + 'modes' => $rows->pluck('mode')->unique()->values(), + 'statuses' => ['可结', '待核实', '不可结'], + ], + ]; + } + protected function contentForDingPayableWorkflows(): array { $normalizeDate = static function (mixed $value): string { diff --git a/config/business_navigation.php b/config/business_navigation.php index 781fabee..0f3ec568 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -430,6 +430,7 @@ return [ ['slug' => 'compensation-details', 'title' => '赔偿明细'], ['slug' => 'advance-details', 'title' => '借支明细'], ['slug' => 'other-adjustments', 'title' => '其他扣款补款'], + ['slug' => 'courier-surcharge', 'title' => '快递附加费'], ['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'], ['slug' => 'courier-payables-report', 'title' => '快递应付表'], ], diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php new file mode 100644 index 00000000..da278e5b --- /dev/null +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -0,0 +1,128 @@ +@php + $rows = $courierSurchargeRows ?? collect(); + $filters = $courierSurchargeFilters ?? []; + $summary = $courierSurchargeSummary ?? []; + $options = $courierSurchargeOptions ?? []; + $statusClass = static fn (string $status): string => match ($status) { + '可结' => 'is-ready', + '待核实' => 'is-pending', + default => 'is-blocked', + }; +@endphp + +
+ + +
+ +
页面原型 · 演示数据 当前展示的是《快递附加费汇报-9.1.docx》整理出的判定逻辑与脱敏样例,尚未接入共享业务镜像。接入真实数据时,页面将沿用固定只读 Repository、稳定游标和业务镜像。
+
+ +
+
快递附加费结算
先判定可结性,再进入应付核算
+
当前明细
{{ $summary['total'] ?? 0 }} 条
+
可结
{{ $summary['ready'] ?? 0 }} 条
+
待核实
{{ $summary['pending'] ?? 0 }} 条
+
预计附加费
¥{{ number_format((float) ($summary['amount'] ?? 0), 2) }}
+
+ +
+
筛选附加费明细
共 {{ $summary['blocked'] ?? 0 }} 条暂不可结
+
+
+
+
+
+
+
重置
+
+
+ +
+
四种结算模式
按快递报价和业务凭证自动选择
+
+
1聚工单客服核实
改地址、上楼费、超区费、安装费、保费等,读取客服核实明细。
+
2一口价判断
按长宽高、最长边、重量或体积匹配当月固定标准。
+
3等于发出运费
附加费直接关联发货记录运费,必要时按双向运费计算。
+
4快递账单结算
读取乙方账单,大金额或异常记录转客服复核。
+
+
+ +
+
判定结果
金额为含税前的演示值,真实金额以业务镜像为准
+
+ + + + @forelse ($rows as $row) + + + + + @empty + + @endforelse + +
业务单号快递渠道附加费类型结算模式判定依据应付金额状态不可结原因基础字段数据来源
{{ $row['number'] }}{{ $row['courier'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['basis'] }}¥{{ number_format((float) $row['amount'], 2) }}{{ $row['status'] }}{{ $row['reason'] ?: '—' }}
展开
尺寸:{{ $row['dimensions'] }}
重量:{{ $row['weight'] }}
体积:{{ $row['volume'] }}
是否退回:{{ $row['returned'] }}
{{ $row['source'] }}
没有符合筛选条件的附加费明细。
+
+
+ +
+
文档规则速览
后续可迁移为可维护的规则配置
+
退回费通用参考:2 元/kg × 结算重量;部分渠道直接取发出运费。
拦截费匹配查件结扣单类型或客服核实;部分渠道按单价,部分渠道按发出运费 × 2。
超长 / 异形件先取最长边,再按当月阶梯标准匹配;超过 160 cm 或 200 cm 的渠道单独计费。
送货费按体积判断,例如 10 立方米以下 50 元/单。
聚工单类改地址、上楼费、超区费、安装费和保费必须有客服核实结果。
双向运费 / 发出运费部分渠道附加费等于发出运费;方建顺心捷达、国荣圆通等拦截费按发出运费 × 2。
账单复核快递账单先读取乙方明细,大金额或异常记录转客服复核。
不可结处理缺少凭证、未命中标准或账单异常时保留原因,进入人工复核队列。
+
+
diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php new file mode 100644 index 00000000..2d58f8f8 --- /dev/null +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -0,0 +1,56 @@ +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('页面原型 · 演示数据') + ->assertSeeText('四种结算模式') + ->assertSeeText('聚工单客服核实') + ->assertSeeText('一口价判断') + ->assertSeeText('附加费等于发出运费') + ->assertSeeText('快递账单结算') + ->assertSeeText('曹县德邦') + ->assertSeeText('超长费') + ->assertSeeText('可结') + ->assertSeeText('待核实') + ->assertSeeText('不可结') + ->assertSeeText('拦截费') + ->assertSeeText('文档规则速览'); + + } + + public function test_courier_surcharge_page_filters_demo_rows_without_touching_a_repository(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'courier' => '常州松宇货运', + 'status' => '可结', + ])) + ->assertOk() + ->assertSeeText('常州松宇货运') + ->assertSeeText('送货费') + ->assertSeeText('¥50.00') + ->assertSeeText('当前明细') + ->assertDontSee('曹县德邦', false) + ->assertDontSee('霸州百世快运-雅柏', false); + } +} -- 2.47.2 From eec68672718e9163b91f9ed4921ceed1f7272a99 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 2 Sep 2026 13:20:07 +0800 Subject: [PATCH 2/7] feat: reshape surcharge page into quote maintenance --- app/Http/Controllers/Business/Pages.php | 78 +++---- config/business_navigation.php | 2 +- .../courier-surcharge.blade.php | 215 +++++++++--------- .../Business/CourierSurchargePageTest.php | 34 +-- 4 files changed, 161 insertions(+), 168 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 891bfc9d..60b2a205 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -454,55 +454,49 @@ class Pages extends Controller } /** - * 快递附加费规则落地原型。 + * 快递附加费报价维护原型。 * - * 这里使用脱敏演示数据,先把文档中的结算模式、判定依据和可结状态 - * 以页面形式呈现;真实上游镜像接入应沿用独立的同步任务和只读 Repository。 + * 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、 + * 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。 */ protected function contentForCourierSurcharge(): array { $rows = collect([ [ - 'number' => 'SF20260901001', 'courier' => '曹县德邦', 'type' => '超长费', - 'mode' => '一口价判断', 'basis' => '最长边 172 cm > 160 cm,按 8 元/单', - 'amount' => 8.00, 'status' => '可结', 'reason' => '', - 'dimensions' => '172 × 68 × 54 cm', 'weight' => '36 kg', 'volume' => '0.63 m³', - 'returned' => '否', 'source' => '规则配置(演示)', + 'id' => 'SUR-001', 'courier' => '曹县德邦', 'type' => '超长费', + 'mode' => '按尺寸/最长边', 'standard' => '最长边 > 160 cm:8 元/单', + 'condition' => '最长边超过 160 cm', 'effective_at' => '2026-09-01', + 'status' => '已启用', 'remark' => '大件渠道标准', ], [ - 'number' => 'SF20260901002', 'courier' => '曹县德邦', 'type' => '改地址', - 'mode' => '聚工单客服核实', 'basis' => '等待聚工单客服核实明细', - 'amount' => 30.00, 'status' => '待核实', 'reason' => '缺少客服核实结果', - 'dimensions' => '120 × 45 × 40 cm', 'weight' => '18 kg', 'volume' => '0.22 m³', - 'returned' => '否', 'source' => '聚工单(待同步)', + 'id' => 'SUR-002', 'courier' => '曹县德邦', 'type' => '拦截费', + 'mode' => '固定单价', 'standard' => '20 元/单(小件)', + 'condition' => '查件结扣单类型=拦截', 'effective_at' => '2026-09-01', + 'status' => '已启用', 'remark' => '小件渠道适用', ], [ - 'number' => 'SF20260901003', 'courier' => '常州松宇货运', 'type' => '送货费', - 'mode' => '一口价判断', 'basis' => '体积 8.4 m³ ≤ 10 m³,按 50 元/单', - 'amount' => 50.00, 'status' => '可结', 'reason' => '', - 'dimensions' => '240 × 140 × 250 cm', 'weight' => '420 kg', 'volume' => '8.4 m³', - 'returned' => '否', 'source' => '规则配置(演示)', + 'id' => 'SUR-003', 'courier' => '常州松宇货运', 'type' => '送货费', + 'mode' => '按体积', 'standard' => '体积 ≤ 10 m³:50 元/单', + 'condition' => '计费体积不超过 10 m³', 'effective_at' => '2026-09-01', + 'status' => '已启用', 'remark' => '送货上门费用', ], [ - 'number' => 'SF20260901004', 'courier' => '霸州百世快运-雅柏', 'type' => '退回费', - 'mode' => '一口价判断', 'basis' => '结算重量 18 kg × 2 元/kg', - 'amount' => 36.00, 'status' => '可结', 'reason' => '', - 'dimensions' => '110 × 50 × 45 cm', 'weight' => '18 kg', 'volume' => '0.25 m³', - 'returned' => '是', 'source' => '规则配置(演示)', + 'id' => 'SUR-004', 'courier' => '霸州百世快运-雅柏', 'type' => '退回费', + 'mode' => '按重量', 'standard' => '2 元/kg', + 'condition' => '退回件,按结算重量计费', 'effective_at' => '2026-09-01', + 'status' => '已启用', 'remark' => '退回费通用标准', ], [ - 'number' => 'SF20260901005', 'courier' => '北京韵达-佰硕', 'type' => '异形件', - 'mode' => '一口价判断', 'basis' => '尺寸/重量未命中当月阶梯标准', - 'amount' => 0.00, 'status' => '不可结', 'reason' => '未匹配到有效阶梯价格', - 'dimensions' => '205 × 95 × 88 cm', 'weight' => '72 kg', 'volume' => '1.71 m³', - 'returned' => '否', 'source' => '规则配置(演示)', + 'id' => 'SUR-005', 'courier' => '常州德邦', 'type' => '上楼费', + 'mode' => '聚工单客服核实', 'standard' => '读取聚工单附加费明细', + 'condition' => '存在客服核实的上楼工单', 'effective_at' => '2026-09-01', + 'status' => '已启用', 'remark' => '不在本页直接填写金额', ], [ - 'number' => 'SF20260901006', 'courier' => '菏泽顺帆', 'type' => '发出运费附加', - 'mode' => '附加费等于发出运费', 'basis' => '关联发货记录运费 88 元', - 'amount' => 88.00, 'status' => '可结', 'reason' => '', - 'dimensions' => '100 × 40 × 35 cm', 'weight' => '15 kg', 'volume' => '0.14 m³', - 'returned' => '否', 'source' => '发货运费(演示)', + 'id' => 'SUR-006', 'courier' => '方建顺心捷达', 'type' => '拦截费', + 'mode' => '发出运费倍数', 'standard' => '发出运费 × 2', + 'condition' => '拦截成功,关联发出运费', 'effective_at' => '2026-09-01', + 'status' => '已启用', 'remark' => '双向运费', ], ]); @@ -521,8 +515,10 @@ class Pages extends Controller } } if ($filters['keyword'] !== '' - && ! str_contains($row['number'], $filters['keyword']) - && ! str_contains($row['basis'], $filters['keyword'])) { + && ! str_contains($row['courier'], $filters['keyword']) + && ! str_contains($row['type'], $filters['keyword']) + && ! str_contains($row['standard'], $filters['keyword']) + && ! str_contains($row['condition'], $filters['keyword'])) { return false; } @@ -535,10 +531,10 @@ class Pages extends Controller 'courierSurchargeFilters' => $filters, 'courierSurchargeSummary' => [ 'total' => $filtered->count(), - 'ready' => $filtered->where('status', '可结')->count(), - 'pending' => $filtered->where('status', '待核实')->count(), - 'blocked' => $filtered->where('status', '不可结')->count(), - 'amount' => $filtered->sum('amount'), + 'active' => $filtered->where('status', '已启用')->count(), + 'inactive' => $filtered->where('status', '已停用')->count(), + 'couriers' => $filtered->pluck('courier')->unique()->count(), + 'types' => $filtered->pluck('type')->unique()->count(), ], 'courierSurchargeOptions' => [ 'couriers' => $rows->pluck('courier')->unique()->sort()->values(), @@ -547,8 +543,8 @@ class Pages extends Controller '退回费', '拦截费', '改地址', '异形件', '超长费', '上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加', ]), - 'modes' => $rows->pluck('mode')->unique()->values(), - 'statuses' => ['可结', '待核实', '不可结'], + 'modes' => collect(['固定单价', '按重量', '按体积', '按尺寸/最长边', '聚工单客服核实', '取发出运费', '发出运费倍数', '按账单']), + 'statuses' => ['已启用', '已停用'], ], ]; } diff --git a/config/business_navigation.php b/config/business_navigation.php index 0f3ec568..cfdb968c 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -430,7 +430,7 @@ return [ ['slug' => 'compensation-details', 'title' => '赔偿明细'], ['slug' => 'advance-details', 'title' => '借支明细'], ['slug' => 'other-adjustments', 'title' => '其他扣款补款'], - ['slug' => 'courier-surcharge', 'title' => '快递附加费'], + ['slug' => 'courier-surcharge', 'title' => '附加费报价维护'], ['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'], ['slug' => 'courier-payables-report', 'title' => '快递应付表'], ], diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index da278e5b..ecf3570c 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -3,126 +3,123 @@ $filters = $courierSurchargeFilters ?? []; $summary = $courierSurchargeSummary ?? []; $options = $courierSurchargeOptions ?? []; - $statusClass = static fn (string $status): string => match ($status) { - '可结' => 'is-ready', - '待核实' => 'is-pending', - default => 'is-blocked', - }; + $statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive'; @endphp -
+
-
- -
页面原型 · 演示数据 当前展示的是《快递附加费汇报-9.1.docx》整理出的判定逻辑与脱敏样例,尚未接入共享业务镜像。接入真实数据时,页面将沿用固定只读 Repository、稳定游标和业务镜像。
+
附加费报价维护原型 · 演示数据 先维护“快递 + 附加费类型 + 结算方式 + 标准 + 生效条件”,后续结算页面将按启用中的报价规则计算。当前新增、编辑和启停操作只在本页演示,刷新后恢复样例,尚未写入共享业务镜像。
+ +
+
快递附加费报价
统一维护各快递的附加费结算规则
+
当前规则
{{ $summary['total'] ?? 0 }} 条
+
已启用
{{ $summary['active'] ?? 0 }} 条
+
已停用
{{ $summary['inactive'] ?? 0 }} 条
+
覆盖快递
{{ $summary['couriers'] ?? 0 }} 家
-
-
快递附加费结算
先判定可结性,再进入应付核算
-
当前明细
{{ $summary['total'] ?? 0 }} 条
-
可结
{{ $summary['ready'] ?? 0 }} 条
-
待核实
{{ $summary['pending'] ?? 0 }} 条
-
预计附加费
¥{{ number_format((float) ($summary['amount'] ?? 0), 2) }}
-
- -
-
筛选附加费明细
共 {{ $summary['blocked'] ?? 0 }} 条暂不可结
-
-
-
-
-
-
-
重置
+
+
筛选报价规则
+ +
+
+
+
+
+
重置
-
-
四种结算模式
按快递报价和业务凭证自动选择
-
-
1聚工单客服核实
改地址、上楼费、超区费、安装费、保费等,读取客服核实明细。
-
2一口价判断
按长宽高、最长边、重量或体积匹配当月固定标准。
-
3等于发出运费
附加费直接关联发货记录运费,必要时按双向运费计算。
-
4快递账单结算
读取乙方账单,大金额或异常记录转客服复核。
+
+
报价规则列表
规则按生效时间应用到后续结算页面
+
+ @forelse ($rows as $row) + + @empty + + @endforelse +
规则编号快递名称附加费类型结算方式计价标准生效条件生效日期状态备注操作
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
没有符合条件的报价规则,请点击“新增报价规则”。
+
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。
+
+ + -
-
判定结果
金额为含税前的演示值,真实金额以业务镜像为准
-
- - - - @forelse ($rows as $row) - - - - - @empty - - @endforelse - -
业务单号快递渠道附加费类型结算模式判定依据应付金额状态不可结原因基础字段数据来源
{{ $row['number'] }}{{ $row['courier'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['basis'] }}¥{{ number_format((float) $row['amount'], 2) }}{{ $row['status'] }}{{ $row['reason'] ?: '—' }}
展开
尺寸:{{ $row['dimensions'] }}
重量:{{ $row['weight'] }}
体积:{{ $row['volume'] }}
是否退回:{{ $row['returned'] }}
{{ $row['source'] }}
没有符合筛选条件的附加费明细。
-
-
- -
-
文档规则速览
后续可迁移为可维护的规则配置
-
退回费通用参考:2 元/kg × 结算重量;部分渠道直接取发出运费。
拦截费匹配查件结扣单类型或客服核实;部分渠道按单价,部分渠道按发出运费 × 2。
超长 / 异形件先取最长边,再按当月阶梯标准匹配;超过 160 cm 或 200 cm 的渠道单独计费。
送货费按体积判断,例如 10 立方米以下 50 元/单。
聚工单类改地址、上楼费、超区费、安装费和保费必须有客服核实结果。
双向运费 / 发出运费部分渠道附加费等于发出运费;方建顺心捷达、国荣圆通等拦截费按发出运费 × 2。
账单复核快递账单先读取乙方明细,大金额或异常记录转客服复核。
不可结处理缺少凭证、未命中标准或账单异常时保留原因,进入人工复核队列。
-
+
diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php index 2d58f8f8..52f5ead8 100644 --- a/tests/Feature/Business/CourierSurchargePageTest.php +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -7,7 +7,7 @@ use Tests\Feature\FeatureTestCase; class CourierSurchargePageTest extends FeatureTestCase { - public function test_courier_surcharge_page_renders_document_logic_and_demo_rows(): void + public function test_courier_surcharge_page_renders_quote_maintenance_and_demo_rules(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); @@ -17,24 +17,25 @@ class CourierSurchargePageTest extends FeatureTestCase 'page' => 'courier-surcharge', ])) ->assertOk() - ->assertSeeText('快递附加费') - ->assertSeeText('页面原型 · 演示数据') - ->assertSeeText('四种结算模式') - ->assertSeeText('聚工单客服核实') - ->assertSeeText('一口价判断') - ->assertSeeText('附加费等于发出运费') - ->assertSeeText('快递账单结算') + ->assertSeeText('附加费报价维护') + ->assertSeeText('附加费报价维护原型 · 演示数据') + ->assertSeeText('报价规则列表') + ->assertSeeText('新增报价规则') + ->assertSeeText('结算方式') + ->assertSeeText('计价标准') + ->assertSeeText('生效条件') ->assertSeeText('曹县德邦') ->assertSeeText('超长费') - ->assertSeeText('可结') - ->assertSeeText('待核实') - ->assertSeeText('不可结') - ->assertSeeText('拦截费') - ->assertSeeText('文档规则速览'); + ->assertSeeText('按尺寸/最长边') + ->assertSeeText('8 元/单') + ->assertSeeText('已启用') + ->assertSeeText('后续结算页面将按启用中的报价规则计算'); + + $this->assertTrue(true); } - public function test_courier_surcharge_page_filters_demo_rows_without_touching_a_repository(): void + public function test_courier_surcharge_page_filters_demo_rules_without_touching_a_repository(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); @@ -43,13 +44,12 @@ class CourierSurchargePageTest extends FeatureTestCase 'section' => 'courier-payables', 'page' => 'courier-surcharge', 'courier' => '常州松宇货运', - 'status' => '可结', + 'status' => '已启用', ])) ->assertOk() ->assertSeeText('常州松宇货运') ->assertSeeText('送货费') - ->assertSeeText('¥50.00') - ->assertSeeText('当前明细') + ->assertSeeText('当前规则') ->assertDontSee('曹县德邦', false) ->assertDontSee('霸州百世快运-雅柏', false); } -- 2.47.2 From 4820caef5c097ffbbff6e38a2f67fd84ed191842 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 2 Sep 2026 14:03:41 +0800 Subject: [PATCH 3/7] feat: allow multiple surcharge types per quote --- .../courier-payables/courier-surcharge.blade.php | 14 ++++++++++---- .../Feature/Business/CourierSurchargePageTest.php | 2 ++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index ecf3570c..7ae7faef 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -29,9 +29,9 @@ .sm-table-wrap { overflow:auto; } .sm-table { width:100%; min-width:82rem; border-collapse:separate; border-spacing:0; font-size:.75rem; } .sm-table th,.sm-table td { padding:.62rem .7rem; border-right:1px solid var(--sm-border); border-bottom:1px solid var(--sm-border); text-align:left; vertical-align:top; white-space:nowrap; } .sm-table th { position:sticky; top:0; z-index:1; background:var(--sm-soft); font-weight:700; } .sm-table tr:hover td { background:#fcfaf4; } .sm-table .wrap { max-width:18rem; white-space:normal; line-height:1.5; } .sm-table .actions { display:flex; gap:.35rem; } .sm-table .actions button { height:1.8rem; padding:0 .5rem; font-size:.7rem; } .sm-badge { display:inline-flex; align-items:center; padding:.15rem .5rem; border-radius:999px; font-size:.7rem; font-weight:700; } .sm-badge.is-active { background:#edf5e8; color:#53793e; } .sm-badge.is-inactive { background:#fff4df; color:#935f07; } .sm-empty { padding:3rem 1rem; text-align:center; color:var(--sm-muted); } .sm-rule-note { padding:1rem 1.15rem; color:var(--sm-muted); font-size:.75rem; line-height:1.65; background:#fffdf8; } - .sm-modal { position:fixed; inset:0; z-index:70; display:none; align-items:flex-start; justify-content:center; padding:4rem 1rem; overflow-y:auto; background:rgba(23,23,23,.42); } .sm-modal.is-open { display:flex; } .sm-modal__panel { width:min(42rem,100%); overflow:hidden; border:1px solid var(--sm-border); border-radius:.75rem; background:#fff; box-shadow:0 1rem 3rem rgba(23,23,23,.2); } .sm-modal__head { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.15rem; border-bottom:1px solid var(--sm-border); } .sm-modal__head h2 { margin:0; font-size:1rem; } .sm-modal__close { border:0; background:transparent; color:var(--sm-muted); cursor:pointer; } .sm-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.8rem; padding:1.15rem; } .sm-form__field { display:grid; gap:.3rem; } .sm-form__field--full { grid-column:span 2; } .sm-form__field label { color:var(--sm-muted); font-size:.72rem; font-weight:600; } .sm-form input,.sm-form select { height:2.25rem; } .sm-form textarea { min-height:4rem; padding:.55rem .6rem; resize:vertical; } .sm-form__foot { grid-column:span 2; display:flex; justify-content:flex-end; gap:.5rem; margin-top:.2rem; } + .sm-modal { position:fixed; inset:0; z-index:70; display:none; align-items:flex-start; justify-content:center; padding:4rem 1rem; overflow-y:auto; background:rgba(23,23,23,.42); } .sm-modal.is-open { display:flex; } .sm-modal__panel { width:min(42rem,100%); overflow:hidden; border:1px solid var(--sm-border); border-radius:.75rem; background:#fff; box-shadow:0 1rem 3rem rgba(23,23,23,.2); } .sm-modal__head { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.15rem; border-bottom:1px solid var(--sm-border); } .sm-modal__head h2 { margin:0; font-size:1rem; } .sm-modal__close { border:0; background:transparent; color:var(--sm-muted); cursor:pointer; } .sm-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.8rem; padding:1.15rem; } .sm-form__field { display:grid; gap:.3rem; } .sm-form__field--full { grid-column:span 2; } .sm-form__field label,.sm-form__field legend { color:var(--sm-muted); font-size:.72rem; font-weight:600; } .sm-form__types { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.45rem; margin:0; padding:0; border:0; } .sm-type-option { display:flex; align-items:center; gap:.35rem; min-height:2.1rem; padding:.35rem .45rem; border:1px solid var(--sm-border); border-radius:.4rem; background:#fffdf8; color:var(--sm-ink); font-size:.72rem; cursor:pointer; } .sm-type-option:has(input:checked) { border-color:var(--sm-accent); background:var(--sm-soft); color:var(--sm-accent); font-weight:700; } .sm-type-option input { width:1rem; height:1rem; accent-color:var(--sm-accent); } .sm-form input,.sm-form select { height:2.25rem; } .sm-form textarea { min-height:4rem; padding:.55rem .6rem; resize:vertical; } .sm-form__foot { grid-column:span 2; display:flex; justify-content:flex-end; gap:.5rem; margin-top:.2rem; } @media (max-width:1100px) { .sm-summary { grid-template-columns:repeat(3,1fr); } .sm-summary__intro { grid-column:span 3; border-bottom:1px solid var(--sm-border); } .sm-toolbar { grid-template-columns:repeat(3,minmax(0,1fr)); } .sm-actions { grid-column:span 3; } } - @media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form { grid-template-columns:minmax(0,1fr); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } } + @media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form { grid-template-columns:minmax(0,1fr); } .sm-form__types { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } }
附加费报价维护原型 · 演示数据 先维护“快递 + 附加费类型 + 结算方式 + 标准 + 生效条件”,后续结算页面将按启用中的报价规则计算。当前新增、编辑和启停操作只在本页演示,刷新后恢复样例,尚未写入共享业务镜像。
@@ -72,7 +72,7 @@

新增报价规则

-
+
附加费类型(可多选)
@foreach (($options['types'] ?? []) as $option)@endforeach
@@ -97,7 +97,10 @@ modal.addEventListener('click', (event) => { if (event.target === modal) close(); }); root.querySelectorAll('[data-surcharge-edit]').forEach((button) => button.addEventListener('click', () => { const row = button.closest('[data-surcharge-row]'); const data = JSON.parse(row.dataset.rule); editingRow = row; title.textContent = '编辑报价规则'; - Object.entries(data).forEach(([key, value]) => { const field = form.elements.namedItem(key); if (field) field.value = value; }); modal.classList.add('is-open'); + ['courier', 'mode', 'standard', 'condition', 'effective_at', 'remark'].forEach((key) => { const field = form.elements.namedItem(key); if (field) field.value = data[key] || ''; }); + const selectedTypes = (data.type || '').split('、').map((item) => item.trim()).filter(Boolean); + form.querySelectorAll('input[name="types[]"]').forEach((checkbox) => { checkbox.checked = selectedTypes.includes(checkbox.value); }); + modal.classList.add('is-open'); })); root.querySelectorAll('[data-surcharge-toggle]').forEach((button) => button.addEventListener('click', () => { const badge = button.closest('tr').querySelector('[data-status]'); const active = badge.textContent.trim() === '已启用'; badge.textContent = active ? '已停用' : '已启用'; badge.classList.toggle('is-active', !active); badge.classList.toggle('is-inactive', active); button.textContent = active ? '启用' : '停用'; @@ -105,6 +108,9 @@ form.addEventListener('submit', (event) => { event.preventDefault(); const values = Object.fromEntries(new FormData(form).entries()); + const selectedTypes = Array.from(form.querySelectorAll('input[name="types[]"]:checked')).map((checkbox) => checkbox.value); + if (!selectedTypes.length) { form.querySelector('#surcharge-form-types').focus(); return; } + values.type = selectedTypes.join('、'); if (editingRow) { const cells = editingRow.querySelectorAll('td'); [values.courier, values.type, values.mode, values.standard, values.condition, values.effective_at, values.remark].forEach((value, index) => { cells[index + 1].textContent = value || '—'; }); } else { diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php index 52f5ead8..9a8a545e 100644 --- a/tests/Feature/Business/CourierSurchargePageTest.php +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -24,6 +24,8 @@ class CourierSurchargePageTest extends FeatureTestCase ->assertSeeText('结算方式') ->assertSeeText('计价标准') ->assertSeeText('生效条件') + ->assertSeeText('附加费类型(可多选)') + ->assertSee('name="types[]"', false) ->assertSeeText('曹县德邦') ->assertSeeText('超长费') ->assertSeeText('按尺寸/最长边') -- 2.47.2 From 85769c7fb530e49c35aafebf164a6a01b99ce168 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 2 Sep 2026 14:45:28 +0800 Subject: [PATCH 4/7] feat: add courier scope and reconciler fields --- app/Http/Controllers/Business/Pages.php | 22 ++++++++++------- .../courier-surcharge.blade.php | 24 +++++++++++-------- .../Business/CourierSurchargePageTest.php | 9 +++++++ 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 60b2a205..2ef0be60 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -463,37 +463,37 @@ class Pages extends Controller { $rows = collect([ [ - 'id' => 'SUR-001', 'courier' => '曹县德邦', 'type' => '超长费', + 'id' => 'SUR-001', 'courier' => '曹县德邦', 'scope' => '自有快递', 'reconciler' => '张会计', 'type' => '超长费', 'mode' => '按尺寸/最长边', 'standard' => '最长边 > 160 cm:8 元/单', 'condition' => '最长边超过 160 cm', 'effective_at' => '2026-09-01', 'status' => '已启用', 'remark' => '大件渠道标准', ], [ - 'id' => 'SUR-002', 'courier' => '曹县德邦', 'type' => '拦截费', + 'id' => 'SUR-002', 'courier' => '曹县德邦', 'scope' => '自有快递', 'reconciler' => '张会计', 'type' => '拦截费', 'mode' => '固定单价', 'standard' => '20 元/单(小件)', 'condition' => '查件结扣单类型=拦截', 'effective_at' => '2026-09-01', 'status' => '已启用', 'remark' => '小件渠道适用', ], [ - 'id' => 'SUR-003', 'courier' => '常州松宇货运', 'type' => '送货费', + 'id' => 'SUR-003', 'courier' => '常州松宇货运', 'scope' => '代发仓', 'reconciler' => '李会计', 'type' => '送货费', 'mode' => '按体积', 'standard' => '体积 ≤ 10 m³:50 元/单', 'condition' => '计费体积不超过 10 m³', 'effective_at' => '2026-09-01', 'status' => '已启用', 'remark' => '送货上门费用', ], [ - 'id' => 'SUR-004', 'courier' => '霸州百世快运-雅柏', 'type' => '退回费', + 'id' => 'SUR-004', 'courier' => '霸州百世快运-雅柏', 'scope' => '代发仓', 'reconciler' => '李会计', 'type' => '退回费', 'mode' => '按重量', 'standard' => '2 元/kg', 'condition' => '退回件,按结算重量计费', 'effective_at' => '2026-09-01', 'status' => '已启用', 'remark' => '退回费通用标准', ], [ - 'id' => 'SUR-005', 'courier' => '常州德邦', 'type' => '上楼费', + 'id' => 'SUR-005', 'courier' => '常州德邦', 'scope' => '自有快递', 'reconciler' => '王会计', 'type' => '上楼费', 'mode' => '聚工单客服核实', 'standard' => '读取聚工单附加费明细', 'condition' => '存在客服核实的上楼工单', 'effective_at' => '2026-09-01', 'status' => '已启用', 'remark' => '不在本页直接填写金额', ], [ - 'id' => 'SUR-006', 'courier' => '方建顺心捷达', 'type' => '拦截费', + 'id' => 'SUR-006', 'courier' => '方建顺心捷达', 'scope' => '代发仓', 'reconciler' => '待指定', 'type' => '拦截费', 'mode' => '发出运费倍数', 'standard' => '发出运费 × 2', 'condition' => '拦截成功,关联发出运费', 'effective_at' => '2026-09-01', 'status' => '已启用', 'remark' => '双向运费', @@ -506,10 +506,12 @@ class Pages extends Controller '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), 'status' => $clean(request('status', ''), 20), ]; $filtered = $rows->filter(static function (array $row) use ($filters): bool { - foreach (['courier', 'type', 'mode', 'status'] as $key) { + foreach (['courier', 'type', 'mode', 'scope', 'reconciler', 'status'] as $key) { if ($filters[$key] !== '' && $row[$key] !== $filters[$key]) { return false; } @@ -518,7 +520,9 @@ class Pages extends Controller && ! str_contains($row['courier'], $filters['keyword']) && ! str_contains($row['type'], $filters['keyword']) && ! str_contains($row['standard'], $filters['keyword']) - && ! str_contains($row['condition'], $filters['keyword'])) { + && ! str_contains($row['condition'], $filters['keyword']) + && ! str_contains($row['scope'], $filters['keyword']) + && ! str_contains($row['reconciler'], $filters['keyword'])) { return false; } @@ -544,6 +548,8 @@ class Pages extends Controller '上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加', ]), 'modes' => collect(['固定单价', '按重量', '按体积', '按尺寸/最长边', '聚工单客服核实', '取发出运费', '发出运费倍数', '按账单']), + 'scopes' => ['自有快递', '代发仓'], + 'reconcilers' => $rows->pluck('reconciler')->unique()->sort()->values(), 'statuses' => ['已启用', '已停用'], ], ]; diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index 7ae7faef..cc847214 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -22,7 +22,7 @@ .sm-card { overflow:hidden; border:1px solid var(--sm-border); border-radius:.75rem; background:#fff; } .sm-card__head { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.85rem 1.15rem; border-bottom:1px solid var(--sm-border); } .sm-card__title { display:flex; align-items:center; gap:.45rem; font-size:.9rem; font-weight:700; } .sm-card__title .material-icons-outlined { color:var(--sm-accent); font-size:1.1rem; } .sm-card__hint { color:var(--sm-muted); font-size:.72rem; } - .sm-toolbar { display:grid; grid-template-columns:minmax(12rem,1.4fr) repeat(4,minmax(9rem,1fr)) auto; gap:.7rem; align-items:end; padding:1rem 1.15rem; background:#fffdf8; } + .sm-toolbar { display:grid; grid-template-columns:minmax(12rem,1.35fr) repeat(6,minmax(8rem,1fr)) auto; gap:.7rem; align-items:end; padding:1rem 1.15rem; background:#fffdf8; } .sm-field { display:grid; gap:.3rem; min-width:0; } .sm-field label { color:var(--sm-muted); font-size:.72rem; font-weight:600; } .sm-field input,.sm-field select,.sm-form input,.sm-form select,.sm-form textarea { width:100%; min-width:0; padding:0 .6rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fff; color:var(--sm-ink); font:inherit; } .sm-field input,.sm-field select { height:2.2rem; } .sm-field input:focus,.sm-field select:focus,.sm-form input:focus,.sm-form select:focus,.sm-form textarea:focus { border-color:var(--sm-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.18); } .sm-actions { display:flex; gap:.45rem; } .sm-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.2rem; padding:0 .75rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fff; color:var(--sm-ink); font:inherit; font-weight:600; text-decoration:none; white-space:nowrap; cursor:pointer; } .sm-button .material-icons-outlined { font-size:1rem; } .sm-button--primary { border-color:var(--sm-accent); background:var(--sm-accent); color:#fff; } .sm-button--primary:hover { background:#6a5129; } @@ -34,7 +34,7 @@ @media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form { grid-template-columns:minmax(0,1fr); } .sm-form__types { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } } -
附加费报价维护原型 · 演示数据 先维护“快递 + 附加费类型 + 结算方式 + 标准 + 生效条件”,后续结算页面将按启用中的报价规则计算。当前新增、编辑和启停操作只在本页演示,刷新后恢复样例,尚未写入共享业务镜像。
+
附加费报价维护原型 · 演示数据 先维护“快递 + 归属 + 附加费类型 + 结算方式 + 标准 + 对账人”,后续结算页面将按启用中的报价规则计算。当前新增、编辑和启停操作只在本页演示,刷新后恢复样例,尚未写入共享业务镜像。
快递附加费报价
统一维护各快递的附加费结算规则
@@ -49,8 +49,10 @@
+
+
重置
@@ -58,11 +60,11 @@
报价规则列表
规则按生效时间应用到后续结算页面
-
+
规则编号快递名称附加费类型结算方式计价标准生效条件生效日期状态备注操作
@forelse ($rows as $row) - + @empty - + @endforelse
规则编号快递名称快递归属附加费类型结算方式计价标准生效条件生效日期对账人状态备注操作
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['reconciler'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
没有符合条件的报价规则,请点击“新增报价规则”。
没有符合条件的报价规则,请点击“新增报价规则”。
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。
@@ -72,11 +74,13 @@

新增报价规则

+
附加费类型(可多选)
@foreach (($options['types'] ?? []) as $option)@endforeach
+
@@ -97,7 +101,7 @@ modal.addEventListener('click', (event) => { if (event.target === modal) close(); }); root.querySelectorAll('[data-surcharge-edit]').forEach((button) => button.addEventListener('click', () => { const row = button.closest('[data-surcharge-row]'); const data = JSON.parse(row.dataset.rule); editingRow = row; title.textContent = '编辑报价规则'; - ['courier', 'mode', 'standard', 'condition', 'effective_at', 'remark'].forEach((key) => { const field = form.elements.namedItem(key); if (field) field.value = data[key] || ''; }); + ['courier', 'scope', 'mode', 'standard', 'condition', 'effective_at', 'reconciler', 'remark'].forEach((key) => { const field = form.elements.namedItem(key); if (field) field.value = data[key] || ''; }); const selectedTypes = (data.type || '').split('、').map((item) => item.trim()).filter(Boolean); form.querySelectorAll('input[name="types[]"]').forEach((checkbox) => { checkbox.checked = selectedTypes.includes(checkbox.value); }); modal.classList.add('is-open'); @@ -112,14 +116,14 @@ if (!selectedTypes.length) { form.querySelector('#surcharge-form-types').focus(); return; } values.type = selectedTypes.join('、'); if (editingRow) { - const cells = editingRow.querySelectorAll('td'); [values.courier, values.type, values.mode, values.standard, values.condition, values.effective_at, values.remark].forEach((value, index) => { cells[index + 1].textContent = value || '—'; }); + const cells = editingRow.querySelectorAll('td'); [[1, values.courier], [2, values.scope], [3, values.type], [4, values.mode], [5, values.standard], [6, values.condition], [7, values.effective_at], [8, values.reconciler], [10, values.remark]].forEach(([index, value]) => { cells[index].textContent = value || '—'; }); } else { const table = root.querySelector('.sm-table tbody'); const empty = table.querySelector('.sm-empty')?.closest('tr'); empty?.remove(); const row = document.createElement('tr'); const cells = [ - `演示-${Date.now().toString().slice(-4)}`, values.courier, values.type, values.mode, - values.standard, values.condition, values.effective_at, '已启用', values.remark || '—', + `演示-${Date.now().toString().slice(-4)}`, values.courier, values.scope, values.type, values.mode, + values.standard, values.condition, values.effective_at, values.reconciler, '已启用', values.remark || '—', ]; - cells.forEach((value, index) => { const cell = document.createElement('td'); cell.textContent = value; if ([4, 5, 8].includes(index)) cell.classList.add('wrap'); if (index === 7) { cell.textContent = ''; const badge = document.createElement('span'); badge.className = 'sm-badge is-active'; badge.dataset.status = ''; badge.textContent = '已启用'; cell.appendChild(badge); } row.appendChild(cell); }); + cells.forEach((value, index) => { const cell = document.createElement('td'); cell.textContent = value; if ([5, 6, 10].includes(index)) cell.classList.add('wrap'); if (index === 9) { cell.textContent = ''; const badge = document.createElement('span'); badge.className = 'sm-badge is-active'; badge.dataset.status = ''; badge.textContent = '已启用'; cell.appendChild(badge); } row.appendChild(cell); }); const actionCell = document.createElement('td'); const actionWrap = document.createElement('div'); actionWrap.className = 'sm-actions'; const toggleButton = document.createElement('button'); toggleButton.className = 'sm-button'; toggleButton.type = 'button'; toggleButton.dataset.surchargeToggle = ''; toggleButton.textContent = '停用'; actionWrap.appendChild(toggleButton); actionCell.appendChild(actionWrap); row.appendChild(actionCell); table.appendChild(row); toggleButton.addEventListener('click', () => { const badge = row.querySelector('[data-status]'); const active = badge.textContent.trim() === '已启用'; badge.textContent = active ? '已停用' : '已启用'; badge.classList.toggle('is-active', !active); badge.classList.toggle('is-inactive', active); toggleButton.textContent = active ? '启用' : '停用'; }); } diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php index 9a8a545e..8446b77c 100644 --- a/tests/Feature/Business/CourierSurchargePageTest.php +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -24,6 +24,11 @@ class CourierSurchargePageTest extends FeatureTestCase ->assertSeeText('结算方式') ->assertSeeText('计价标准') ->assertSeeText('生效条件') + ->assertSeeText('快递归属') + ->assertSeeText('自有快递') + ->assertSeeText('代发仓') + ->assertSeeText('对账人') + ->assertSeeText('张会计') ->assertSeeText('附加费类型(可多选)') ->assertSee('name="types[]"', false) ->assertSeeText('曹县德邦') @@ -46,11 +51,15 @@ class CourierSurchargePageTest extends FeatureTestCase 'section' => 'courier-payables', 'page' => 'courier-surcharge', 'courier' => '常州松宇货运', + 'scope' => '代发仓', + 'reconciler' => '李会计', 'status' => '已启用', ])) ->assertOk() ->assertSeeText('常州松宇货运') ->assertSeeText('送货费') + ->assertSeeText('代发仓') + ->assertSeeText('李会计') ->assertSeeText('当前规则') ->assertDontSee('曹县德邦', false) ->assertDontSee('霸州百世快运-雅柏', false); -- 2.47.2 From e6118f102d837de583744ddc192b3d183255211e Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 2 Sep 2026 14:57:31 +0800 Subject: [PATCH 5/7] fix: move reconciler after courier scope --- .../courier-payables/courier-surcharge.blade.php | 16 ++++++++-------- .../Business/CourierSurchargePageTest.php | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index cc847214..e68ba726 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -50,9 +50,9 @@
+
-
重置
@@ -60,9 +60,9 @@
报价规则列表
规则按生效时间应用到后续结算页面
-
+
规则编号快递名称快递归属附加费类型结算方式计价标准生效条件生效日期对账人状态备注操作
@forelse ($rows as $row) - + @empty @endforelse @@ -75,12 +75,12 @@
+
附加费类型(可多选)
@foreach (($options['types'] ?? []) as $option)@endforeach
-
@@ -116,14 +116,14 @@ if (!selectedTypes.length) { form.querySelector('#surcharge-form-types').focus(); return; } values.type = selectedTypes.join('、'); if (editingRow) { - const cells = editingRow.querySelectorAll('td'); [[1, values.courier], [2, values.scope], [3, values.type], [4, values.mode], [5, values.standard], [6, values.condition], [7, values.effective_at], [8, values.reconciler], [10, values.remark]].forEach(([index, value]) => { cells[index].textContent = value || '—'; }); + const cells = editingRow.querySelectorAll('td'); [[1, values.courier], [2, values.scope], [3, values.reconciler], [4, values.type], [5, values.mode], [6, values.standard], [7, values.condition], [8, values.effective_at], [10, values.remark]].forEach(([index, value]) => { cells[index].textContent = value || '—'; }); } else { const table = root.querySelector('.sm-table tbody'); const empty = table.querySelector('.sm-empty')?.closest('tr'); empty?.remove(); const row = document.createElement('tr'); const cells = [ - `演示-${Date.now().toString().slice(-4)}`, values.courier, values.scope, values.type, values.mode, - values.standard, values.condition, values.effective_at, values.reconciler, '已启用', values.remark || '—', + `演示-${Date.now().toString().slice(-4)}`, values.courier, values.scope, values.reconciler, values.type, values.mode, + values.standard, values.condition, values.effective_at, '已启用', values.remark || '—', ]; - cells.forEach((value, index) => { const cell = document.createElement('td'); cell.textContent = value; if ([5, 6, 10].includes(index)) cell.classList.add('wrap'); if (index === 9) { cell.textContent = ''; const badge = document.createElement('span'); badge.className = 'sm-badge is-active'; badge.dataset.status = ''; badge.textContent = '已启用'; cell.appendChild(badge); } row.appendChild(cell); }); + cells.forEach((value, index) => { const cell = document.createElement('td'); cell.textContent = value; if ([6, 7, 10].includes(index)) cell.classList.add('wrap'); if (index === 9) { cell.textContent = ''; const badge = document.createElement('span'); badge.className = 'sm-badge is-active'; badge.dataset.status = ''; badge.textContent = '已启用'; cell.appendChild(badge); } row.appendChild(cell); }); const actionCell = document.createElement('td'); const actionWrap = document.createElement('div'); actionWrap.className = 'sm-actions'; const toggleButton = document.createElement('button'); toggleButton.className = 'sm-button'; toggleButton.type = 'button'; toggleButton.dataset.surchargeToggle = ''; toggleButton.textContent = '停用'; actionWrap.appendChild(toggleButton); actionCell.appendChild(actionWrap); row.appendChild(actionCell); table.appendChild(row); toggleButton.addEventListener('click', () => { const badge = row.querySelector('[data-status]'); const active = badge.textContent.trim() === '已启用'; badge.textContent = active ? '已停用' : '已启用'; badge.classList.toggle('is-active', !active); badge.classList.toggle('is-inactive', active); toggleButton.textContent = active ? '启用' : '停用'; }); } diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php index 8446b77c..6d401371 100644 --- a/tests/Feature/Business/CourierSurchargePageTest.php +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -31,6 +31,7 @@ class CourierSurchargePageTest extends FeatureTestCase ->assertSeeText('张会计') ->assertSeeText('附加费类型(可多选)') ->assertSee('name="types[]"', false) + ->assertSeeInOrder(['', '', ''], false) ->assertSeeText('曹县德邦') ->assertSeeText('超长费') ->assertSeeText('按尺寸/最长边') -- 2.47.2 From 67f7a8ad6f9ccf339162123a130b07df8f4b1e6f Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 2 Sep 2026 16:14:53 +0800 Subject: [PATCH 6/7] feat: persist courier surcharge quote maintenance --- .../CourierSurchargeQuoteRepository.php | 201 ++++++++++++++++++ .../Business/CourierSurchargeQuotes.php | 88 ++++++++ app/Http/Controllers/Business/Pages.php | 84 +------- app/Models/Business/CourierSurchargeQuote.php | 51 +++++ ..._create_courier_surcharge_quotes_table.php | 43 ++++ .../courier-surcharge.blade.php | 45 ++-- routes/admin.php | 10 + .../Business/CourierSurchargePageTest.php | 89 +++++++- 8 files changed, 497 insertions(+), 114 deletions(-) create mode 100644 app/Business/CourierSurchargeQuoteRepository.php create mode 100644 app/Http/Controllers/Business/CourierSurchargeQuotes.php create mode 100644 app/Models/Business/CourierSurchargeQuote.php create mode 100644 database/migrations/2026_09_02_000000_create_courier_surcharge_quotes_table.php diff --git a/app/Business/CourierSurchargeQuoteRepository.php b/app/Business/CourierSurchargeQuoteRepository.php new file mode 100644 index 00000000..1d0af5a7 --- /dev/null +++ b/app/Business/CourierSurchargeQuoteRepository.php @@ -0,0 +1,201 @@ +getTable()); + } catch (QueryException) { + return false; + } + } + + /** + * @param array $filters + * @return array{ready:bool, rows:Collection, summary:array, options:array} + */ + public function pageData(array $filters): array + { + if (! $this->ready()) { + return [ + 'ready' => false, + 'rows' => collect(), + 'summary' => ['total' => 0, 'active' => 0, 'inactive' => 0, 'couriers' => 0], + 'options' => $this->emptyOptions(), + ]; + } + + try { + $all = CourierSurchargeQuote::query() + ->orderByDesc('effective_at') + ->orderByDesc('id') + ->get(); + } catch (QueryException) { + return [ + 'ready' => false, + 'rows' => collect(), + 'summary' => ['total' => 0, 'active' => 0, 'inactive' => 0, 'couriers' => 0], + 'options' => $this->emptyOptions(), + ]; + } + + $filtered = $all->filter(function (CourierSurchargeQuote $quote) use ($filters): bool { + foreach (['courier', 'scope', 'reconciler', 'mode'] as $key) { + if (($filters[$key] ?? '') !== '' && (string) $quote->{$key} !== $filters[$key]) { + return false; + } + } + + if (($filters['type'] ?? '') !== '' && ! in_array($filters['type'], (array) $quote->types, true)) { + return false; + } + + if (($filters['status'] ?? '') !== '' && $quote->statusLabel() !== $filters['status']) { + return false; + } + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $searchable = implode('|', [ + $quote->courier, + $quote->scope, + $quote->reconciler, + $quote->typeLabel(), + $quote->mode, + $quote->standard, + $quote->condition, + (string) $quote->remark, + ]); + + if (! str_contains($searchable, $keyword)) { + return false; + } + } + + return true; + })->values(); + + return [ + 'ready' => true, + 'rows' => $filtered->map(fn (CourierSurchargeQuote $quote): array => $this->present($quote)), + 'summary' => [ + 'total' => $filtered->count(), + 'active' => $filtered->where('status', CourierSurchargeQuote::STATUS_ACTIVE)->count(), + 'inactive' => $filtered->where('status', CourierSurchargeQuote::STATUS_INACTIVE)->count(), + 'couriers' => $filtered->pluck('courier')->unique()->count(), + ], + 'options' => [ + 'couriers' => $all->pluck('courier')->filter()->unique()->sort()->values(), + 'types' => collect(self::TYPES), + 'modes' => collect(self::MODES), + 'scopes' => self::SCOPES, + 'reconcilers' => $all->pluck('reconciler')->filter()->unique()->sort()->values(), + 'statuses' => ['已启用', '已停用'], + ], + ]; + } + + /** @param array $data */ + public function create(array $data, int $userId): CourierSurchargeQuote + { + return CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [ + 'status' => CourierSurchargeQuote::STATUS_ACTIVE, + 'created_by' => $userId, + ]); + } + + /** @param array $data */ + public function update(CourierSurchargeQuote $quote, array $data, int $userId): CourierSurchargeQuote + { + $quote->update($this->attributes($data, $userId)); + + return $quote->fresh(); + } + + public function setStatus(CourierSurchargeQuote $quote, string $status, int $userId): CourierSurchargeQuote + { + abort_unless(in_array($status, [CourierSurchargeQuote::STATUS_ACTIVE, CourierSurchargeQuote::STATUS_INACTIVE], true), 422, '报价规则状态不合法。'); + + $quote->forceFill(['status' => $status, 'updated_by' => $userId])->save(); + + return $quote->fresh(); + } + + /** @param array $data */ + private function attributes(array $data, int $userId): array + { + return [ + 'company_id' => company_id(), + 'courier' => trim((string) $data['courier']), + 'scope' => trim((string) $data['scope']), + 'reconciler' => trim((string) $data['reconciler']), + 'types' => array_values(array_unique(array_map('strval', $data['types']))), + 'mode' => trim((string) $data['mode']), + 'standard' => trim((string) $data['standard']), + 'condition' => trim((string) $data['condition']), + 'effective_at' => $data['effective_at'], + 'remark' => trim((string) ($data['remark'] ?? '')) ?: null, + 'updated_by' => $userId, + ]; + } + + /** @return array */ + private function present(CourierSurchargeQuote $quote): array + { + return [ + 'id' => 'SUR-' . str_pad((string) $quote->id, 3, '0', STR_PAD_LEFT), + 'db_id' => $quote->id, + 'courier' => $quote->courier, + 'scope' => $quote->scope, + 'reconciler' => $quote->reconciler, + 'types' => array_values((array) $quote->types), + 'type' => $quote->typeLabel(), + 'mode' => $quote->mode, + 'standard' => $quote->standard, + 'condition' => $quote->condition, + 'effective_at' => optional($quote->effective_at)->format('Y-m-d'), + 'status_code' => $quote->status, + 'status' => $quote->statusLabel(), + 'remark' => $quote->remark, + ]; + } + + /** @return array */ + private function emptyOptions(): array + { + return [ + 'couriers' => collect(), + 'types' => collect(self::TYPES), + 'modes' => collect(self::MODES), + 'scopes' => self::SCOPES, + 'reconcilers' => collect(), + 'statuses' => ['已启用', '已停用'], + ]; + } +} diff --git a/app/Http/Controllers/Business/CourierSurchargeQuotes.php b/app/Http/Controllers/Business/CourierSurchargeQuotes.php new file mode 100644 index 00000000..62633b9f --- /dev/null +++ b/app/Http/Controllers/Business/CourierSurchargeQuotes.php @@ -0,0 +1,88 @@ +middleware('auth'); + $this->middleware('company.identify'); + $this->middleware('business.page:courier-payables,courier-surcharge'); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。'); + + $this->quotes->create($this->validated($request), (int) user_id()); + + return $this->redirect()->with('success', '报价规则已新增并保存。'); + } + + public function update(Request $request, CourierSurchargeQuote $quote): RedirectResponse + { + $this->ensureCompany($quote); + abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。'); + + $this->quotes->update($quote, $this->validated($request), (int) user_id()); + + return $this->redirect()->with('success', '报价规则已更新并保存。'); + } + + public function setStatus(Request $request, CourierSurchargeQuote $quote): RedirectResponse + { + $this->ensureCompany($quote); + abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。'); + + $status = $request->validate([ + 'status' => ['required', 'in:' . CourierSurchargeQuote::STATUS_ACTIVE . ',' . CourierSurchargeQuote::STATUS_INACTIVE], + ])['status']; + $this->quotes->setStatus($quote, $status, (int) user_id()); + + return $this->redirect()->with('success', '报价规则状态已更新。'); + } + + /** @return array */ + private function validated(Request $request): array + { + $data = $request->validate([ + 'courier' => ['required', 'string', 'max:191'], + 'scope' => ['required', 'in:' . implode(',', CourierSurchargeQuoteRepository::SCOPES)], + 'reconciler' => ['required', 'string', 'max:100'], + 'types' => ['required', 'array', 'min:1', 'max:11'], + 'types.*' => ['string', 'in:' . implode(',', CourierSurchargeQuoteRepository::TYPES)], + 'mode' => ['required', 'in:' . implode(',', CourierSurchargeQuoteRepository::MODES)], + 'standard' => ['required', 'string', 'max:2000'], + 'condition' => ['required', 'string', 'max:2000'], + 'effective_at' => ['required', 'date_format:Y-m-d'], + 'remark' => ['nullable', 'string', 'max:2000'], + ]); + + $data['courier'] = trim($data['courier']); + $data['reconciler'] = trim($data['reconciler']); + $data['types'] = array_values(array_unique($data['types'])); + + return $data; + } + + private function ensureCompany(CourierSurchargeQuote $quote): void + { + abort_unless((int) $quote->company_id === (int) company_id(), 404, '报价规则不存在。'); + } + + private function redirect(): RedirectResponse + { + return redirect()->route('business.pages.show', [ + 'company_id' => company_id() ?: 1, + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + ]); + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 2ef0be60..66742150 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -11,6 +11,7 @@ use App\Models\Banking\MarketingAccountBill; use App\Models\Business\CourierAgreementSignature; use App\Models\Business\CourierSigningSubject; use App\Models\Business\InstallationDetail; +use App\Business\CourierSurchargeQuoteRepository; use App\Models\StoreReceivables\StoreZhongcaiReconciliation; use App\Utilities\SidebarPermissions; use Illuminate\Http\Request; @@ -71,6 +72,7 @@ class Pages extends Controller private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows, private readonly SupplierPayableReportRepository $supplierPayableReports, private readonly UpstreamQueryCacheFactory $upstreamCaches, + private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes, ) { $this->middleware('auth'); $this->middleware('company.identify'); @@ -461,45 +463,6 @@ class Pages extends Controller */ protected function contentForCourierSurcharge(): array { - $rows = collect([ - [ - 'id' => 'SUR-001', 'courier' => '曹县德邦', 'scope' => '自有快递', 'reconciler' => '张会计', 'type' => '超长费', - 'mode' => '按尺寸/最长边', 'standard' => '最长边 > 160 cm:8 元/单', - 'condition' => '最长边超过 160 cm', 'effective_at' => '2026-09-01', - 'status' => '已启用', 'remark' => '大件渠道标准', - ], - [ - 'id' => 'SUR-002', 'courier' => '曹县德邦', 'scope' => '自有快递', 'reconciler' => '张会计', 'type' => '拦截费', - 'mode' => '固定单价', 'standard' => '20 元/单(小件)', - 'condition' => '查件结扣单类型=拦截', 'effective_at' => '2026-09-01', - 'status' => '已启用', 'remark' => '小件渠道适用', - ], - [ - 'id' => 'SUR-003', 'courier' => '常州松宇货运', 'scope' => '代发仓', 'reconciler' => '李会计', 'type' => '送货费', - 'mode' => '按体积', 'standard' => '体积 ≤ 10 m³:50 元/单', - 'condition' => '计费体积不超过 10 m³', 'effective_at' => '2026-09-01', - 'status' => '已启用', 'remark' => '送货上门费用', - ], - [ - 'id' => 'SUR-004', 'courier' => '霸州百世快运-雅柏', 'scope' => '代发仓', 'reconciler' => '李会计', 'type' => '退回费', - 'mode' => '按重量', 'standard' => '2 元/kg', - 'condition' => '退回件,按结算重量计费', 'effective_at' => '2026-09-01', - 'status' => '已启用', 'remark' => '退回费通用标准', - ], - [ - 'id' => 'SUR-005', 'courier' => '常州德邦', 'scope' => '自有快递', 'reconciler' => '王会计', 'type' => '上楼费', - 'mode' => '聚工单客服核实', 'standard' => '读取聚工单附加费明细', - 'condition' => '存在客服核实的上楼工单', 'effective_at' => '2026-09-01', - 'status' => '已启用', 'remark' => '不在本页直接填写金额', - ], - [ - 'id' => 'SUR-006', 'courier' => '方建顺心捷达', 'scope' => '代发仓', 'reconciler' => '待指定', 'type' => '拦截费', - 'mode' => '发出运费倍数', 'standard' => '发出运费 × 2', - 'condition' => '拦截成功,关联发出运费', 'effective_at' => '2026-09-01', - 'status' => '已启用', 'remark' => '双向运费', - ], - ]); - $clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length); $filters = [ 'keyword' => $clean(request('keyword', ''), 40), @@ -510,48 +473,15 @@ class Pages extends Controller 'reconciler' => $clean(request('reconciler', ''), 40), 'status' => $clean(request('status', ''), 20), ]; - $filtered = $rows->filter(static function (array $row) use ($filters): bool { - foreach (['courier', 'type', 'mode', 'scope', 'reconciler', 'status'] as $key) { - if ($filters[$key] !== '' && $row[$key] !== $filters[$key]) { - return false; - } - } - if ($filters['keyword'] !== '' - && ! str_contains($row['courier'], $filters['keyword']) - && ! str_contains($row['type'], $filters['keyword']) - && ! str_contains($row['standard'], $filters['keyword']) - && ! str_contains($row['condition'], $filters['keyword']) - && ! str_contains($row['scope'], $filters['keyword']) - && ! str_contains($row['reconciler'], $filters['keyword'])) { - return false; - } - - return true; - })->values(); + $data = $this->courierSurchargeQuotes->pageData($filters); return [ 'view' => 'business.pages.courier-payables.courier-surcharge', - 'courierSurchargeRows' => $filtered, + 'courierSurchargeRows' => $data['rows'], 'courierSurchargeFilters' => $filters, - 'courierSurchargeSummary' => [ - 'total' => $filtered->count(), - 'active' => $filtered->where('status', '已启用')->count(), - 'inactive' => $filtered->where('status', '已停用')->count(), - 'couriers' => $filtered->pluck('courier')->unique()->count(), - 'types' => $filtered->pluck('type')->unique()->count(), - ], - 'courierSurchargeOptions' => [ - 'couriers' => $rows->pluck('courier')->unique()->sort()->values(), - // 文档定义的十类附加费全部进入筛选器,即使当前演示样例暂未覆盖每一类。 - 'types' => collect([ - '退回费', '拦截费', '改地址', '异形件', '超长费', - '上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加', - ]), - 'modes' => collect(['固定单价', '按重量', '按体积', '按尺寸/最长边', '聚工单客服核实', '取发出运费', '发出运费倍数', '按账单']), - 'scopes' => ['自有快递', '代发仓'], - 'reconcilers' => $rows->pluck('reconciler')->unique()->sort()->values(), - 'statuses' => ['已启用', '已停用'], - ], + 'courierSurchargeSummary' => $data['summary'], + 'courierSurchargeOptions' => $data['options'], + 'courierSurchargeReady' => $data['ready'], ]; } diff --git a/app/Models/Business/CourierSurchargeQuote.php b/app/Models/Business/CourierSurchargeQuote.php new file mode 100644 index 00000000..8cf53c08 --- /dev/null +++ b/app/Models/Business/CourierSurchargeQuote.php @@ -0,0 +1,51 @@ + 'integer', + 'types' => 'array', + 'effective_at' => 'date:Y-m-d', + 'created_by' => 'integer', + 'updated_by' => 'integer', + ]; + + public function statusLabel(): string + { + return $this->status === self::STATUS_ACTIVE ? '已启用' : '已停用'; + } + + public function typeLabel(): string + { + return implode('、', array_values(array_filter(array_map( + static fn ($type): string => trim((string) $type), + (array) $this->types, + )))); + } +} diff --git a/database/migrations/2026_09_02_000000_create_courier_surcharge_quotes_table.php b/database/migrations/2026_09_02_000000_create_courier_surcharge_quotes_table.php new file mode 100644 index 00000000..50dffe09 --- /dev/null +++ b/database/migrations/2026_09_02_000000_create_courier_surcharge_quotes_table.php @@ -0,0 +1,43 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->string('courier', 191); + $table->string('scope', 32); + $table->string('reconciler', 100); + $table->text('types'); + $table->string('mode', 64); + $table->text('standard'); + $table->text('condition'); + $table->date('effective_at'); + $table->string('status', 16)->default('active'); + $table->text('remark')->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'status'], 'courier_surcharge_company_status_index'); + $table->index(['company_id', 'courier'], 'courier_surcharge_company_courier_index'); + $table->index(['company_id', 'effective_at'], 'courier_surcharge_company_effective_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_courier_surcharge_quotes'); + } +}; diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index e68ba726..a4690207 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -3,6 +3,7 @@ $filters = $courierSurchargeFilters ?? []; $summary = $courierSurchargeSummary ?? []; $options = $courierSurchargeOptions ?? []; + $ready = (bool) ($courierSurchargeReady ?? false); $statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive'; @endphp @@ -34,7 +35,7 @@ @media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form { grid-template-columns:minmax(0,1fr); } .sm-form__types { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } } -
附加费报价维护原型 · 演示数据 先维护“快递 + 归属 + 附加费类型 + 结算方式 + 标准 + 对账人”,后续结算页面将按启用中的报价规则计算。当前新增、编辑和启停操作只在本页演示,刷新后恢复样例,尚未写入共享业务镜像。
+
{{ $ready ? '正式维护数据' : '数据表尚未就绪' }} {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。' }}
快递附加费报价
统一维护各快递的附加费结算规则
@@ -45,7 +46,7 @@
-
筛选报价规则
+
筛选报价规则
@@ -62,7 +63,7 @@
报价规则列表
规则按生效时间应用到后续结算页面
规则编号快递名称快递归属对账人附加费类型结算方式计价标准生效条件生效日期状态备注操作
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['reconciler'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['reconciler'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
没有符合条件的报价规则,请点击“新增报价规则”。
快递名称快递归属对账人
@forelse ($rows as $row) - + @empty @endforelse @@ -72,7 +73,9 @@ @@ -94,40 +97,22 @@ const modal = root.querySelector('[data-surcharge-modal]'); const form = root.querySelector('[data-surcharge-form]'); const title = root.querySelector('#surcharge-modal-title'); - let editingRow = null; - const close = () => { modal.classList.remove('is-open'); editingRow = null; form.reset(); title.textContent = '新增报价规则'; }; - root.querySelector('[data-surcharge-open]')?.addEventListener('click', () => { title.textContent = '新增报价规则'; modal.classList.add('is-open'); root.querySelector('#surcharge-form-courier').focus(); }); + const methodField = form.querySelector('[data-surcharge-method]'); + const createAction = form.action; + const close = () => { modal.classList.remove('is-open'); form.reset(); form.action = createAction; methodField.value = 'POST'; title.textContent = '新增报价规则'; }; + root.querySelector('[data-surcharge-open]')?.addEventListener('click', () => { close(); modal.classList.add('is-open'); root.querySelector('#surcharge-form-courier').focus(); }); root.querySelectorAll('[data-surcharge-close]').forEach((button) => button.addEventListener('click', close)); modal.addEventListener('click', (event) => { if (event.target === modal) close(); }); root.querySelectorAll('[data-surcharge-edit]').forEach((button) => button.addEventListener('click', () => { - const row = button.closest('[data-surcharge-row]'); const data = JSON.parse(row.dataset.rule); editingRow = row; title.textContent = '编辑报价规则'; + const row = button.closest('[data-surcharge-row]'); const data = JSON.parse(row.dataset.rule); form.action = row.dataset.updateUrl; methodField.value = 'PATCH'; title.textContent = '编辑报价规则'; ['courier', 'scope', 'mode', 'standard', 'condition', 'effective_at', 'reconciler', 'remark'].forEach((key) => { const field = form.elements.namedItem(key); if (field) field.value = data[key] || ''; }); - const selectedTypes = (data.type || '').split('、').map((item) => item.trim()).filter(Boolean); + const selectedTypes = Array.isArray(data.types) ? data.types : (data.type || '').split('、').map((item) => item.trim()).filter(Boolean); form.querySelectorAll('input[name="types[]"]').forEach((checkbox) => { checkbox.checked = selectedTypes.includes(checkbox.value); }); modal.classList.add('is-open'); })); - root.querySelectorAll('[data-surcharge-toggle]').forEach((button) => button.addEventListener('click', () => { - const badge = button.closest('tr').querySelector('[data-status]'); const active = badge.textContent.trim() === '已启用'; badge.textContent = active ? '已停用' : '已启用'; badge.classList.toggle('is-active', !active); badge.classList.toggle('is-inactive', active); button.textContent = active ? '启用' : '停用'; - })); form.addEventListener('submit', (event) => { - event.preventDefault(); - const values = Object.fromEntries(new FormData(form).entries()); const selectedTypes = Array.from(form.querySelectorAll('input[name="types[]"]:checked')).map((checkbox) => checkbox.value); - if (!selectedTypes.length) { form.querySelector('#surcharge-form-types').focus(); return; } - values.type = selectedTypes.join('、'); - if (editingRow) { - const cells = editingRow.querySelectorAll('td'); [[1, values.courier], [2, values.scope], [3, values.reconciler], [4, values.type], [5, values.mode], [6, values.standard], [7, values.condition], [8, values.effective_at], [10, values.remark]].forEach(([index, value]) => { cells[index].textContent = value || '—'; }); - } else { - const table = root.querySelector('.sm-table tbody'); const empty = table.querySelector('.sm-empty')?.closest('tr'); empty?.remove(); const row = document.createElement('tr'); - const cells = [ - `演示-${Date.now().toString().slice(-4)}`, values.courier, values.scope, values.reconciler, values.type, values.mode, - values.standard, values.condition, values.effective_at, '已启用', values.remark || '—', - ]; - cells.forEach((value, index) => { const cell = document.createElement('td'); cell.textContent = value; if ([6, 7, 10].includes(index)) cell.classList.add('wrap'); if (index === 9) { cell.textContent = ''; const badge = document.createElement('span'); badge.className = 'sm-badge is-active'; badge.dataset.status = ''; badge.textContent = '已启用'; cell.appendChild(badge); } row.appendChild(cell); }); - const actionCell = document.createElement('td'); const actionWrap = document.createElement('div'); actionWrap.className = 'sm-actions'; const toggleButton = document.createElement('button'); toggleButton.className = 'sm-button'; toggleButton.type = 'button'; toggleButton.dataset.surchargeToggle = ''; toggleButton.textContent = '停用'; actionWrap.appendChild(toggleButton); actionCell.appendChild(actionWrap); row.appendChild(actionCell); table.appendChild(row); - toggleButton.addEventListener('click', () => { const badge = row.querySelector('[data-status]'); const active = badge.textContent.trim() === '已启用'; badge.textContent = active ? '已停用' : '已启用'; badge.classList.toggle('is-active', !active); badge.classList.toggle('is-inactive', active); toggleButton.textContent = active ? '启用' : '停用'; }); - } - close(); + if (!selectedTypes.length) { event.preventDefault(); form.querySelector('#surcharge-form-types').focus(); } }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && modal.classList.contains('is-open')) close(); }); })(); diff --git a/routes/admin.php b/routes/admin.php index dcaf8838..d2ed3236 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Business\CourierQuoteChanges; use App\Http\Controllers\Business\CourierQuoteSignatures; use App\Http\Controllers\Business\CourierAgreementSignatures; +use App\Http\Controllers\Business\CourierSurchargeQuotes; use Illuminate\Support\Facades\Route; use Modules\EcommerceFinance\Http\Controllers\AccountBills; use Modules\EcommerceFinance\Http\Controllers\AlipayBills; @@ -127,6 +128,15 @@ Route::get('workspace/financial-operations/installation-details/details/{guid}', Route::get('workspace/financial-operations/installation-details/export', 'Business\Pages@exportInstallationDetails') ->name('business.installation-details.export'); +Route::prefix('workspace/courier-payables/courier-surcharge') + ->name('business.courier-surcharge.') + ->middleware('business.page:courier-payables,courier-surcharge') + ->group(function () { + Route::post('/', [CourierSurchargeQuotes::class, 'store'])->name('store'); + Route::patch('{quote}', [CourierSurchargeQuotes::class, 'update'])->name('update'); + Route::post('{quote}/status', [CourierSurchargeQuotes::class, 'setStatus'])->name('status'); + }); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php index 6d401371..a4fd325d 100644 --- a/tests/Feature/Business/CourierSurchargePageTest.php +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -3,13 +3,15 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; +use App\Models\Business\CourierSurchargeQuote; use Tests\Feature\FeatureTestCase; class CourierSurchargePageTest extends FeatureTestCase { - public function test_courier_surcharge_page_renders_quote_maintenance_and_demo_rules(): void + public function test_courier_surcharge_page_renders_persisted_quote_rules(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->createQuote(); $this->loginAs() ->get(route('business.pages.show', [ @@ -18,7 +20,7 @@ class CourierSurchargePageTest extends FeatureTestCase ])) ->assertOk() ->assertSeeText('附加费报价维护') - ->assertSeeText('附加费报价维护原型 · 演示数据') + ->assertSeeText('正式维护数据') ->assertSeeText('报价规则列表') ->assertSeeText('新增报价规则') ->assertSeeText('结算方式') @@ -37,15 +39,26 @@ class CourierSurchargePageTest extends FeatureTestCase ->assertSeeText('按尺寸/最长边') ->assertSeeText('8 元/单') ->assertSeeText('已启用') - ->assertSeeText('后续结算页面将按启用中的报价规则计算'); - - $this->assertTrue(true); - + ->assertSeeText('后续结算页面将按启用中的规则计算'); } - public function test_courier_surcharge_page_filters_demo_rules_without_touching_a_repository(): void + public function test_courier_surcharge_page_filters_persisted_rules(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->createQuote(); + CourierSurchargeQuote::query()->create([ + 'company_id' => company_id(), + 'courier' => '常州松宇货运', + 'scope' => '代发仓', + 'reconciler' => '李会计', + 'types' => ['送货费'], + 'mode' => '按体积', + 'standard' => '体积 ≤ 10 m³:50 元/单', + 'condition' => '计费体积不超过 10 m³', + 'effective_at' => '2026-09-02', + 'status' => CourierSurchargeQuote::STATUS_ACTIVE, + 'remark' => null, + ]); $this->loginAs() ->get(route('business.pages.show', [ @@ -65,4 +78,66 @@ class CourierSurchargePageTest extends FeatureTestCase ->assertDontSee('', false) ->assertDontSee('', false); } + + public function test_courier_surcharge_store_update_and_status_are_persistent(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->loginAs(); + + $payload = [ + 'courier' => '测试快递', + 'scope' => '代发仓', + 'reconciler' => '李会计', + 'types' => ['拦截费', '改地址'], + 'mode' => '固定单价', + 'standard' => '12 元/单', + 'condition' => '拦截成功', + 'effective_at' => '2026-09-02', + 'remark' => '接口保存测试', + ]; + + $this->post(route('business.courier-surcharge.store'), $payload) + ->assertRedirect(route('business.pages.show', [ + 'company_id' => company_id(), 'section' => 'courier-payables', 'page' => 'courier-surcharge', + ])); + + $quote = CourierSurchargeQuote::query()->where('courier', '测试快递')->firstOrFail(); + $this->assertSame(['拦截费', '改地址'], $quote->types); + $this->assertSame(CourierSurchargeQuote::STATUS_ACTIVE, $quote->status); + $this->assertSame((int) user_id(), (int) $quote->created_by); + $this->assertSame((int) user_id(), (int) $quote->updated_by); + + $this->patch(route('business.courier-surcharge.update', ['quote' => $quote->id]), [ + ...$payload, + 'courier' => '测试快递(已修改)', + 'types' => ['保费'], + ])->assertRedirect(); + + $quote->refresh(); + $this->assertSame('测试快递(已修改)', $quote->courier); + $this->assertSame(['保费'], $quote->types); + + $this->post(route('business.courier-surcharge.status', ['quote' => $quote->id]), [ + 'status' => CourierSurchargeQuote::STATUS_INACTIVE, + ])->assertRedirect(); + + $this->assertSame(CourierSurchargeQuote::STATUS_INACTIVE, $quote->fresh()->status); + } + + private function createQuote(): CourierSurchargeQuote + { + return CourierSurchargeQuote::query()->create([ + 'company_id' => company_id(), + 'courier' => '曹县德邦', + 'scope' => '自有快递', + 'reconciler' => '张会计', + 'types' => ['超长费'], + 'mode' => '按尺寸/最长边', + 'standard' => '最长边 > 160 cm:8 元/单', + 'condition' => '最长边超过 160 cm', + 'effective_at' => '2026-09-01', + 'status' => CourierSurchargeQuote::STATUS_ACTIVE, + 'remark' => '大件渠道标准', + ]); + } } -- 2.47.2 From 928c3e51e0ed1957698d767457b85ed20103d3ad Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 2 Sep 2026 16:23:04 +0800 Subject: [PATCH 7/7] docs: clarify surcharge maintenance controller --- app/Http/Controllers/Business/Pages.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 66742150..628dc698 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -456,7 +456,7 @@ class Pages extends Controller } /** - * 快递附加费报价维护原型。 + * 快递附加费报价维护。 * * 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、 * 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。 -- 2.47.2
规则编号快递名称快递归属对账人附加费类型结算方式计价标准生效条件生效日期状态备注操作
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['reconciler'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['reconciler'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
@csrf
没有符合条件的报价规则,请点击“新增报价规则”。
曹县德邦霸州百世快运-雅柏