快递附加费报价正式维护模块 #97

Merged
zhoushengchen merged 9 commits from feature/zhoushengchen-courier-surcharge into main 2026-09-02 16:53:43 +08:00
9 changed files with 690 additions and 0 deletions

View File

@ -0,0 +1,201 @@
<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
/**
* 快递附加费报价的固定读写入口。
*
* 页面和控制器不直接操作数据库,所有公司范围、字段白名单和状态转换
* 都在这里集中处理,便于后续结算页面复用同一套报价规则。
*/
class CourierSurchargeQuoteRepository
{
public const TYPES = [
'退回费', '拦截费', '改地址', '异形件', '超长费',
'上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加',
];
public const MODES = [
'固定单价', '按重量', '按体积', '按尺寸/最长边',
'聚工单客服核实', '取发出运费', '发出运费倍数', '按账单',
];
public const SCOPES = ['自有快递', '代发仓'];
public function ready(): bool
{
try {
return Schema::hasTable((new CourierSurchargeQuote())->getTable());
} catch (QueryException) {
return false;
}
}
/**
* @param array<string,string> $filters
* @return array{ready:bool, rows:Collection, summary:array<string,int>, options:array<string,Collection|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<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> */
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<string,Collection|array> */
private function emptyOptions(): array
{
return [
'couriers' => collect(),
'types' => collect(self::TYPES),
'modes' => collect(self::MODES),
'scopes' => self::SCOPES,
'reconcilers' => collect(),
'statuses' => ['已启用', '已停用'],
];
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Business;
use App\Business\CourierSurchargeQuoteRepository;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class CourierSurchargeQuotes extends Controller
{
public function __construct(private readonly CourierSurchargeQuoteRepository $quotes)
{
$this->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<string,mixed> */
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',
]);
}
}

View File

@ -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 {

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class CourierSurchargeQuote extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
protected $table = 'business_courier_surcharge_quotes';
protected $fillable = [
'company_id',
'courier',
'scope',
'reconciler',
'types',
'mode',
'standard',
'condition',
'effective_at',
'status',
'remark',
'created_by',
'updated_by',
];
protected $casts = [
'company_id' => '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,
))));
}
}

View File

@ -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' => '快递应付表'],
],

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('business_courier_surcharge_quotes')) {
return;
}
Schema::create('business_courier_surcharge_quotes', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -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
<section class="surcharge-maintenance" aria-label="附加费报价维护" data-surcharge-maintenance>
<style>
.surcharge-maintenance { --sm-ink:var(--vip-content-ink,#171717); --sm-muted:var(--vip-content-muted,#595959); --sm-accent:var(--vip-content-accent-dark,#856632); --sm-soft:var(--vip-content-accent-soft,#F7F0DE); --sm-border:var(--vip-border,#D8D2C7); display:grid; gap:1rem; color:var(--sm-ink); font-family:JetBrains Mono,"Microsoft YaHei","PingFang SC",monospace; font-size:.8125rem; }
.surcharge-maintenance *, .surcharge-maintenance *::before, .surcharge-maintenance *::after { box-sizing:border-box; }
.sm-notice { display:flex; align-items:flex-start; gap:.65rem; padding:.75rem 1rem; border:1px solid #d7c39d; border-radius:.65rem; background:#fffaf0; color:#6a5129; line-height:1.55; }
.sm-notice .material-icons-outlined { color:var(--sm-accent); font-size:1.15rem; }
.sm-notice strong { color:var(--sm-ink); }
.sm-summary { display:grid; grid-template-columns:1.35fr repeat(4,minmax(8rem,1fr)); overflow:hidden; border:1px solid var(--sm-border); border-radius:.75rem; background:#fff; }
.sm-summary__intro, .sm-metric { min-width:0; padding:1rem 1.15rem; }
.sm-summary__intro { display:flex; align-items:center; gap:.75rem; }
.sm-summary__icon { display:grid; flex:none; width:2.4rem; height:2.4rem; place-items:center; border-radius:.6rem; background:var(--sm-soft); color:var(--sm-accent); }
.sm-summary__title { font-size:.95rem; font-weight:700; } .sm-summary__subtitle { margin-top:.2rem; color:var(--sm-muted); font-size:.72rem; }
.sm-metric { border-left:1px solid var(--sm-border); } .sm-metric__label { color:var(--sm-muted); font-size:.72rem; } .sm-metric__value { margin-top:.25rem; font-size:1.2rem; font-weight:700; font-variant-numeric:tabular-nums; } .sm-metric__value.is-active { color:#53793e; } .sm-metric__value.is-inactive { color:#935f07; }
.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.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; }
.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,.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-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; } }
</style>
<div class="sm-notice" role="note"><span class="material-icons-outlined" aria-hidden="true">{{ $ready ? 'verified' : 'info' }}</span><div><strong>{{ $ready ? '正式维护数据' : '数据表尚未就绪' }}</strong> {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。' }}</div></div>
<div class="sm-summary" aria-label="报价维护概览">
<div class="sm-summary__intro"><span class="sm-summary__icon material-icons-outlined" aria-hidden="true">local_shipping</span><div><div class="sm-summary__title">快递附加费报价</div><div class="sm-summary__subtitle">统一维护各快递的附加费结算规则</div></div></div>
<div class="sm-metric"><div class="sm-metric__label">当前规则</div><div class="sm-metric__value">{{ $summary['total'] ?? 0 }} <small>条</small></div></div>
<div class="sm-metric"><div class="sm-metric__label">已启用</div><div class="sm-metric__value is-active">{{ $summary['active'] ?? 0 }} <small>条</small></div></div>
<div class="sm-metric"><div class="sm-metric__label">已停用</div><div class="sm-metric__value is-inactive">{{ $summary['inactive'] ?? 0 }} <small>条</small></div></div>
<div class="sm-metric"><div class="sm-metric__label">覆盖快递</div><div class="sm-metric__value">{{ $summary['couriers'] ?? 0 }} <small>家</small></div></div>
</div>
<div class="sm-card">
<div class="sm-card__head"><div class="sm-card__title"><span class="material-icons-outlined" aria-hidden="true">tune</span>筛选报价规则</div><button class="sm-button sm-button--primary" type="button" data-surcharge-open @disabled(!$ready)><span class="material-icons-outlined" aria-hidden="true">add</span>新增报价规则</button></div>
<form method="GET" class="sm-toolbar" aria-label="报价规则筛选">
<div class="sm-field"><label for="surcharge-keyword">快递名称 / 标准</label><input id="surcharge-keyword" name="keyword" value="{{ $filters['keyword'] ?? '' }}" placeholder="输入关键字"></div>
<div class="sm-field"><label for="surcharge-courier">快递名称</label><select id="surcharge-courier" name="courier"><option value="">全部快递</option>@foreach (($options['couriers'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['courier'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-field"><label for="surcharge-scope">快递归属</label><select id="surcharge-scope" name="scope"><option value="">全部归属</option>@foreach (($options['scopes'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['scope'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-field"><label for="surcharge-reconciler">对账人</label><select id="surcharge-reconciler" name="reconciler"><option value="">全部对账人</option>@foreach (($options['reconcilers'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['reconciler'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-field"><label for="surcharge-type">附加费类型</label><select id="surcharge-type" name="type"><option value="">全部类型</option>@foreach (($options['types'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['type'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-field"><label for="surcharge-mode">结算方式</label><select id="surcharge-mode" name="mode"><option value="">全部方式</option>@foreach (($options['modes'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['mode'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-field"><label for="surcharge-status">状态</label><select id="surcharge-status" name="status"><option value="">全部状态</option>@foreach (($options['statuses'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['status'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-actions"><button class="sm-button sm-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="sm-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a></div>
</form>
</div>
<div class="sm-card">
<div class="sm-card__head"><div class="sm-card__title"><span class="material-icons-outlined" aria-hidden="true">rule</span>报价规则列表</div><div class="sm-card__hint">规则按生效时间应用到后续结算页面</div></div>
<div class="sm-table-wrap"><table class="sm-table"><thead><tr><th>规则编号</th><th>快递名称</th><th>快递归属</th><th>对账人</th><th>附加费类型</th><th>结算方式</th><th>计价标准</th><th>生效条件</th><th>生效日期</th><th>状态</th><th>备注</th><th>操作</th></tr></thead><tbody>
@forelse ($rows as $row)
<tr data-surcharge-row data-rule='@json($row)' data-update-url="{{ route('business.courier-surcharge.update', ['quote' => $row['db_id']]) }}"><td>{{ $row['id'] }}</td><td>{{ $row['courier'] }}</td><td>{{ $row['scope'] }}</td><td>{{ $row['reconciler'] }}</td><td>{{ $row['type'] }}</td><td>{{ $row['mode'] }}</td><td class="wrap">{{ $row['standard'] }}</td><td class="wrap">{{ $row['condition'] }}</td><td>{{ $row['effective_at'] }}</td><td><span class="sm-badge {{ $statusClass($row['status']) }}">{{ $row['status'] }}</span></td><td class="wrap">{{ $row['remark'] ?: '—' }}</td><td><div class="sm-actions"><button class="sm-button" type="button" data-surcharge-edit @disabled(!$ready)>编辑</button><form method="POST" action="{{ route('business.courier-surcharge.status', ['quote' => $row['db_id']]) }}">@csrf<input type="hidden" name="status" value="{{ $row['status_code'] === 'active' ? 'inactive' : 'active' }}"><button class="sm-button" type="submit" @disabled(!$ready)>{{ $row['status'] === '已启用' ? '停用' : '启用' }}</button></form></div></td></tr>
@empty
<tr><td colspan="12"><div class="sm-empty">没有符合条件的报价规则,请点击“新增报价规则”。</div></td></tr>
@endforelse
</tbody></table></div>
<div class="sm-rule-note"><strong>维护建议:</strong>固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。</div>
</div>
<div class="sm-modal" role="dialog" aria-modal="true" aria-labelledby="surcharge-modal-title" data-surcharge-modal>
<div class="sm-modal__panel"><div class="sm-modal__head"><h2 id="surcharge-modal-title">新增报价规则</h2><button class="sm-modal__close" type="button" aria-label="关闭" data-surcharge-close><span class="material-icons-outlined" aria-hidden="true">close</span></button></div>
<form class="sm-form" method="POST" action="{{ route('business.courier-surcharge.store') }}" data-surcharge-form>
@csrf
<input type="hidden" name="_method" value="POST" data-surcharge-method>
<div class="sm-form__field"><label for="surcharge-form-courier">快递名称</label><input id="surcharge-form-courier" name="courier" required placeholder="例如:曹县德邦"></div>
<div class="sm-form__field"><label for="surcharge-form-scope">快递归属</label><select id="surcharge-form-scope" name="scope" required><option value="">请选择</option>@foreach (($options['scopes'] ?? []) as $option)<option value="{{ $option }}">{{ $option }}</option>@endforeach</select></div>
<div class="sm-form__field"><label for="surcharge-form-reconciler">对账人</label><input id="surcharge-form-reconciler" name="reconciler" required placeholder="例如:张会计"></div>
<fieldset class="sm-form__field sm-form__field--full"><legend>附加费类型(可多选)</legend><div class="sm-form__types" id="surcharge-form-types">@foreach (($options['types'] ?? []) as $option)<label class="sm-type-option"><input type="checkbox" name="types[]" value="{{ $option }}"><span>{{ $option }}</span></label>@endforeach</div></fieldset>
<div class="sm-form__field"><label for="surcharge-form-mode">结算方式</label><select id="surcharge-form-mode" name="mode" required><option value="">请选择</option>@foreach (($options['modes'] ?? []) as $option)<option value="{{ $option }}">{{ $option }}</option>@endforeach</select></div>
<div class="sm-form__field"><label for="surcharge-form-standard">计价标准</label><input id="surcharge-form-standard" name="standard" required placeholder="例如:2 元/kg"></div>
<div class="sm-form__field sm-form__field--full"><label for="surcharge-form-condition">生效条件</label><input id="surcharge-form-condition" name="condition" required placeholder="例如:退回件,按结算重量计费"></div>
<div class="sm-form__field"><label for="surcharge-form-date">生效日期</label><input id="surcharge-form-date" type="date" name="effective_at" value="{{ now()->toDateString() }}" required></div>
<div class="sm-form__field"><label for="surcharge-form-remark">备注(可选)</label><input id="surcharge-form-remark" name="remark" placeholder="补充适用范围"></div>
<div class="sm-form__foot"><button class="sm-button" type="button" data-surcharge-close>取消</button><button class="sm-button sm-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">save</span>保存报价规则</button></div>
</form>
</div>
</div>
<script>
(() => {
const root = document.querySelector('[data-surcharge-maintenance]');
if (!root) return;
const modal = root.querySelector('[data-surcharge-modal]');
const form = root.querySelector('[data-surcharge-form]');
const title = root.querySelector('#surcharge-modal-title');
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); 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 = 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');
}));
form.addEventListener('submit', (event) => {
const selectedTypes = Array.from(form.querySelectorAll('input[name="types[]"]:checked')).map((checkbox) => checkbox.value);
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(); });
})();
</script>
</section>

View File

@ -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');

View File

@ -0,0 +1,143 @@
<?php
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_persisted_quote_rules(): void
{
$this->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(['<th>快递名称</th>', '<th>快递归属</th>', '<th>对账人</th>'], 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('<td>曹县德邦</td>', false)
->assertDontSee('<td>霸州百世快运-雅柏</td>', 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' => '大件渠道标准',
]);
}
}