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 8c64d641..628dc698 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'); @@ -447,11 +449,42 @@ 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, }; } + /** + * 快递附加费报价维护。 + * + * 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、 + * 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。 + */ + protected function contentForCourierSurcharge(): array + { + $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), + 'scope' => $clean(request('scope', ''), 20), + 'reconciler' => $clean(request('reconciler', ''), 40), + 'status' => $clean(request('status', ''), 20), + ]; + $data = $this->courierSurchargeQuotes->pageData($filters); + + return [ + 'view' => 'business.pages.courier-payables.courier-surcharge', + 'courierSurchargeRows' => $data['rows'], + 'courierSurchargeFilters' => $filters, + 'courierSurchargeSummary' => $data['summary'], + 'courierSurchargeOptions' => $data['options'], + 'courierSurchargeReady' => $data['ready'], + ]; + } + protected function contentForDingPayableWorkflows(): array { $normalizeDate = static function (mixed $value): string { 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/config/business_navigation.php b/config/business_navigation.php index 781fabee..cfdb968c 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/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 new file mode 100644 index 00000000..a4690207 --- /dev/null +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -0,0 +1,120 @@ +@php + $rows = $courierSurchargeRows ?? collect(); + $filters = $courierSurchargeFilters ?? []; + $summary = $courierSurchargeSummary ?? []; + $options = $courierSurchargeOptions ?? []; + $ready = (bool) ($courierSurchargeReady ?? false); + $statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive'; +@endphp + +
+ + +
{{ $ready ? '正式维护数据' : '数据表尚未就绪' }} {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。' }}
+ +
+
快递附加费报价
统一维护各快递的附加费结算规则
+
当前规则
{{ $summary['total'] ?? 0 }} 条
+
已启用
{{ $summary['active'] ?? 0 }} 条
+
已停用
{{ $summary['inactive'] ?? 0 }} 条
+
覆盖快递
{{ $summary['couriers'] ?? 0 }} 家
+
+ +
+
筛选报价规则
+
+
+
+
+
+
+
+
+
重置
+
+
+ +
+
报价规则列表
规则按生效时间应用到后续结算页面
+
+ @forelse ($rows as $row) + + @empty + + @endforelse +
规则编号快递名称快递归属对账人附加费类型结算方式计价标准生效条件生效日期状态备注操作
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['reconciler'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
@csrf
没有符合条件的报价规则,请点击“新增报价规则”。
+
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。
+
+ + + + +
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 new file mode 100644 index 00000000..a4fd325d --- /dev/null +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -0,0 +1,143 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->createQuote(); + + $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('附加费类型(可多选)') + ->assertSee('name="types[]"', false) + ->assertSeeInOrder(['快递名称', '快递归属', '对账人'], false) + ->assertSeeText('曹县德邦') + ->assertSeeText('超长费') + ->assertSeeText('按尺寸/最长边') + ->assertSeeText('8 元/单') + ->assertSeeText('已启用') + ->assertSeeText('后续结算页面将按启用中的规则计算'); + } + + 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', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'courier' => '常州松宇货运', + 'scope' => '代发仓', + 'reconciler' => '李会计', + 'status' => '已启用', + ])) + ->assertOk() + ->assertSeeText('常州松宇货运') + ->assertSeeText('送货费') + ->assertSeeText('代发仓') + ->assertSeeText('李会计') + ->assertSeeText('当前规则') + ->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' => '大件渠道标准', + ]); + } +}