附加费类型字典自维护(含进仓费) #98

Merged
zhoushengchen merged 3 commits from feature/zhoushengchen-surcharge-type-dictionary into main 2026-09-02 18:22:18 +08:00
11 changed files with 503 additions and 8 deletions

View File

@ -3,6 +3,7 @@
namespace App\Business;
use App\Models\Business\CourierSurchargeQuote;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
@ -27,10 +28,14 @@ class CourierSurchargeQuoteRepository
public const SCOPES = ['自有快递', '代发仓'];
public function __construct(private readonly CourierSurchargeTypeRepository $types)
{
}
public function ready(): bool
{
try {
return Schema::hasTable((new CourierSurchargeQuote())->getTable());
return Schema::hasTable((new CourierSurchargeQuote())->getTable()) && $this->types->ready();
} catch (QueryException) {
return false;
}
@ -101,6 +106,12 @@ class CourierSurchargeQuoteRepository
return true;
})->values();
$typeRecords = $this->types->all();
$activeTypeNames = $typeRecords
->where('status', CourierSurchargeType::STATUS_ACTIVE)
->pluck('name')
->values();
return [
'ready' => true,
'rows' => $filtered->map(fn (CourierSurchargeQuote $quote): array => $this->present($quote)),
@ -112,7 +123,14 @@ class CourierSurchargeQuoteRepository
],
'options' => [
'couriers' => $all->pluck('courier')->filter()->unique()->sort()->values(),
'types' => collect(self::TYPES),
'types' => $activeTypeNames,
'filter_types' => $typeRecords->pluck('name')->values(),
'type_records' => $typeRecords->map(fn ($type): array => [
'id' => $type->id,
'name' => $type->name,
'status' => $type->status,
'status_label' => $type->statusLabel(),
])->values(),
'modes' => collect(self::MODES),
'scopes' => self::SCOPES,
'reconcilers' => $all->pluck('reconciler')->filter()->unique()->sort()->values(),
@ -121,6 +139,18 @@ class CourierSurchargeQuoteRepository
];
}
/** @return list<string> */
public function validTypeNames(): array
{
return $this->types->validNames();
}
/** @return list<string> */
public function activeTypeNames(): array
{
return $this->types->activeNames();
}
/** @param array<string,mixed> $data */
public function create(array $data, int $userId): CourierSurchargeQuote
{
@ -192,6 +222,8 @@ class CourierSurchargeQuoteRepository
return [
'couriers' => collect(),
'types' => collect(self::TYPES),
'filter_types' => collect(self::TYPES),
'type_records' => collect(),
'modes' => collect(self::MODES),
'scopes' => self::SCOPES,
'reconcilers' => collect(),

View File

@ -0,0 +1,154 @@
<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeQuote;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
/**
* 附加费类型字典的固定读写入口。
*
* 类型不做物理删除,停用后保留历史报价中的原值,避免编辑历史规则时
* 意外丢失类型。报价规则和字典均由当前公司全局作用域隔离。
*/
class CourierSurchargeTypeRepository
{
/** @var list<string> */
public const DEFAULT_NAMES = [
'退回费', '拦截费', '改地址', '异形件', '超长费',
'上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加', '进仓费',
];
public function ready(): bool
{
try {
return Schema::hasTable((new CourierSurchargeType())->getTable());
} catch (QueryException) {
return false;
}
}
/** @return Collection<int,CourierSurchargeType> */
public function all(): Collection
{
if (! $this->ready()) {
return collect();
}
try {
$types = CourierSurchargeType::query()
->orderBy('sort_order')
->orderBy('id')
->get();
if ($types->isEmpty() && company_id()) {
$this->ensureDefaults((int) company_id());
$types = CourierSurchargeType::query()
->orderBy('sort_order')
->orderBy('id')
->get();
}
return $types;
} catch (QueryException) {
return collect();
}
}
/** @return list<string> */
public function validNames(): array
{
return $this->all()->pluck('name')->values()->all();
}
/** @return list<string> */
public function activeNames(): array
{
return $this->all()
->where('status', CourierSurchargeType::STATUS_ACTIVE)
->pluck('name')
->values()
->all();
}
public function create(string $name, int $userId): CourierSurchargeType
{
$name = trim($name);
if ($name === '') {
throw ValidationException::withMessages(['name' => '附加费类型不能为空。']);
}
try {
if (CourierSurchargeType::query()->where('name', $name)->exists()) {
throw ValidationException::withMessages(['name' => '当前公司已有同名附加费类型。']);
}
$sortOrder = ((int) CourierSurchargeType::query()->max('sort_order')) + 1;
return CourierSurchargeType::query()->create([
'company_id' => company_id(),
'name' => $name,
'status' => CourierSurchargeType::STATUS_ACTIVE,
'sort_order' => $sortOrder,
'created_by' => $userId,
'updated_by' => $userId,
]);
} catch (QueryException $exception) {
if ((int) $exception->getCode() === 23000) {
throw ValidationException::withMessages(['name' => '当前公司已有同名附加费类型。']);
}
throw $exception;
}
}
public function setStatus(CourierSurchargeType $type, string $status, int $userId): CourierSurchargeType
{
abort_unless(in_array($status, [CourierSurchargeType::STATUS_ACTIVE, CourierSurchargeType::STATUS_INACTIVE], true), 422, '附加费类型状态不合法。');
$type->forceFill([
'status' => $status,
'updated_by' => $userId,
])->save();
return $type->fresh();
}
private function ensureDefaults(int $companyId): void
{
foreach (self::DEFAULT_NAMES as $sortOrder => $name) {
CourierSurchargeType::query()->firstOrCreate(
['company_id' => $companyId, 'name' => $name],
[
'status' => CourierSurchargeType::STATUS_ACTIVE,
'sort_order' => $sortOrder,
'created_by' => null,
'updated_by' => null,
],
);
}
}
/** @return array<string,int> */
public function usageCounts(Collection $types): array
{
if ($types->isEmpty() || ! Schema::hasTable((new CourierSurchargeQuote())->getTable())) {
return [];
}
$counts = array_fill_keys($types->pluck('name')->all(), 0);
CourierSurchargeQuote::query()->get(['types'])->each(function (CourierSurchargeQuote $quote) use (&$counts): void {
foreach (array_unique((array) $quote->types) as $name) {
if (array_key_exists($name, $counts)) {
$counts[$name]++;
}
}
});
return $counts;
}
}

View File

@ -56,8 +56,8 @@ class CourierSurchargeQuotes extends Controller
'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)],
'types' => ['required', 'array', 'min:1', 'max:50'],
'types.*' => ['string', 'in:' . implode(',', $this->quotes->activeTypeNames())],
'mode' => ['required', 'in:' . implode(',', CourierSurchargeQuoteRepository::MODES)],
'standard' => ['required', 'string', 'max:2000'],
'condition' => ['required', 'string', 'max:2000'],

View File

@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Business;
use App\Business\CourierSurchargeTypeRepository;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class CourierSurchargeTypes extends Controller
{
public function __construct(private readonly CourierSurchargeTypeRepository $types)
{
$this->middleware('auth');
$this->middleware('company.identify');
$this->middleware('business.page:courier-payables,courier-surcharge');
}
public function store(Request $request): RedirectResponse
{
abort_unless($this->types->ready(), 503, '附加费类型数据表尚未完成迁移,请联系管理员。');
$data = $request->validate([
'name' => ['required', 'string', 'max:100'],
]);
$this->types->create($data['name'], (int) user_id());
return $this->redirect()->with('success', '附加费类型已新增。');
}
public function setStatus(Request $request, CourierSurchargeType $type): RedirectResponse
{
$this->ensureCompany($type);
abort_unless($this->types->ready(), 503, '附加费类型数据表尚未完成迁移,请联系管理员。');
$status = $request->validate([
'status' => ['required', 'in:' . CourierSurchargeType::STATUS_ACTIVE . ',' . CourierSurchargeType::STATUS_INACTIVE],
])['status'];
$this->types->setStatus($type, $status, (int) user_id());
return $this->redirect()->with('success', '附加费类型状态已更新。');
}
private function ensureCompany(CourierSurchargeType $type): void
{
abort_unless((int) $type->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

@ -12,6 +12,7 @@ use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierSigningSubject;
use App\Models\Business\InstallationDetail;
use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeTypeRepository;
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
@ -73,6 +74,7 @@ class Pages extends Controller
private readonly SupplierPayableReportRepository $supplierPayableReports,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes,
private readonly CourierSurchargeTypeRepository $courierSurchargeTypes,
) {
$this->middleware('auth');
$this->middleware('company.identify');
@ -482,6 +484,7 @@ class Pages extends Controller
'courierSurchargeSummary' => $data['summary'],
'courierSurchargeOptions' => $data['options'],
'courierSurchargeReady' => $data['ready'],
'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()),
];
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class CourierSurchargeType extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
protected $table = 'business_courier_surcharge_types';
protected $fillable = [
'company_id',
'name',
'status',
'sort_order',
'created_by',
'updated_by',
];
protected $casts = [
'company_id' => 'integer',
'sort_order' => 'integer',
'created_by' => 'integer',
'updated_by' => 'integer',
];
public function statusLabel(): string
{
return $this->status === self::STATUS_ACTIVE ? '已启用' : '已停用';
}
}

View File

@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const DEFAULT_TYPES = [
'退回费', '拦截费', '改地址', '异形件', '超长费',
'上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加', '进仓费',
];
public function up(): void
{
if (! Schema::hasTable('business_courier_surcharge_types')) {
Schema::create('business_courier_surcharge_types', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->string('name', 100);
$table->string('status', 16)->default('active');
$table->unsignedInteger('sort_order')->default(0);
$table->unsignedInteger('created_by')->nullable();
$table->unsignedInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['company_id', 'name', 'deleted_at'], 'courier_surcharge_types_name_unique');
$table->index(['company_id', 'status', 'sort_order'], 'courier_surcharge_types_status_index');
});
}
$companyIds = DB::table('companies')->pluck('id');
foreach ($companyIds as $companyId) {
foreach (self::DEFAULT_TYPES as $sortOrder => $name) {
$exists = DB::table('business_courier_surcharge_types')
->where('company_id', $companyId)
->where('name', $name)
->exists();
if (! $exists) {
DB::table('business_courier_surcharge_types')->insert([
'company_id' => $companyId,
'name' => $name,
'status' => 'active',
'sort_order' => $sortOrder,
'created_by' => null,
'updated_by' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
}
public function down(): void
{
Schema::dropIfExists('business_courier_surcharge_types');
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_surcharge_types')) {
return;
}
foreach (DB::table('companies')->pluck('id') as $companyId) {
$query = DB::table('business_courier_surcharge_types')
->where('company_id', $companyId)
->where('name', '进仓费');
if ($query->whereNull('deleted_at')->exists()) {
continue;
}
$sortOrder = ((int) DB::table('business_courier_surcharge_types')
->where('company_id', $companyId)
->max('sort_order')) + 1;
DB::table('business_courier_surcharge_types')->insert([
'company_id' => $companyId,
'name' => '进仓费',
'status' => 'active',
'sort_order' => $sortOrder,
'created_by' => null,
'updated_by' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
public function down(): void
{
// 类型字典不做物理删除,避免回滚迁移时破坏已建立的业务引用。
}
};

View File

@ -4,6 +4,7 @@
$summary = $courierSurchargeSummary ?? [];
$options = $courierSurchargeOptions ?? [];
$ready = (bool) ($courierSurchargeReady ?? false);
$typeUsage = $courierSurchargeTypeUsage ?? [];
$statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive';
@endphp
@ -30,9 +31,10 @@
.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-type-manager { display:grid; gap:1rem; padding:1.15rem; } .sm-type-create { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.7rem; align-items:end; padding-bottom:1rem; border-bottom:1px solid var(--sm-border); } .sm-type-list { display:grid; gap:.55rem; } .sm-type-list__head { display:grid; gap:.2rem; color:var(--sm-muted); font-size:.72rem; line-height:1.5; } .sm-type-list__head strong { color:var(--sm-ink); font-size:.8rem; } .sm-type-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; min-height:2.6rem; padding:.5rem .65rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fffdf8; } .sm-type-row > div { display:flex; align-items:center; flex-wrap:wrap; gap:.45rem; min-width:0; } .sm-type-row small { color:var(--sm-muted); font-size:.68rem; } .sm-type-row form { flex:none; }
.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; } }
@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,.sm-type-create { 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>
@ -46,13 +48,13 @@
</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>
<div class="sm-card__head"><div class="sm-card__title"><span class="material-icons-outlined" aria-hidden="true">tune</span>筛选报价规则</div><div class="sm-actions"><button class="sm-button" type="button" data-type-manage @disabled(!$ready)><span class="material-icons-outlined" aria-hidden="true">settings</span>管理附加费类型</button><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></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-type">附加费类型</label><select id="surcharge-type" name="type"><option value="">全部类型</option>@foreach (($options['filter_types'] ?? $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>
@ -90,19 +92,43 @@
</div>
</div>
<div class="sm-modal" role="dialog" aria-modal="true" aria-labelledby="type-modal-title" data-type-modal>
<div class="sm-modal__panel"><div class="sm-modal__head"><h2 id="type-modal-title">管理附加费类型</h2><button class="sm-modal__close" type="button" aria-label="关闭" data-type-close><span class="material-icons-outlined" aria-hidden="true">close</span></button></div>
<div class="sm-type-manager">
<form class="sm-type-create" method="POST" action="{{ route('business.courier-surcharge.types.store') }}">
@csrf
<div class="sm-field"><label for="new-surcharge-type">新增附加费类型</label><input id="new-surcharge-type" name="name" required maxlength="100" placeholder="例如:偏远地区费"></div>
<button class="sm-button sm-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">add</span>新增类型</button>
</form>
<div class="sm-type-list"><div class="sm-type-list__head"><strong>类型列表</strong><span>停用后不会出现在报价规则表单中,历史规则仍保留原值。</span></div>
@forelse (($options['type_records'] ?? []) as $type)
<div class="sm-type-row"><div><strong>{{ $type['name'] }}</strong><span class="sm-badge {{ $type['status'] === 'active' ? 'is-active' : 'is-inactive' }}">{{ $type['status_label'] }}</span>@if (($typeUsage[$type['name']] ?? 0) > 0)<small>已用于 {{ $typeUsage[$type['name']] }} 条报价</small>@endif</div><form method="POST" action="{{ route('business.courier-surcharge.types.status', ['type' => $type['id']]) }}">@csrf<input type="hidden" name="status" value="{{ $type['status'] === 'active' ? 'inactive' : 'active' }}"><button class="sm-button" type="submit">{{ $type['status'] === 'active' ? '停用' : '启用' }}</button></form></div>
@empty
<div class="sm-empty">类型数据表尚未就绪。</div>
@endforelse
</div>
</div>
</div>
</div>
<script>
(() => {
const root = document.querySelector('[data-surcharge-maintenance]');
if (!root) return;
const modal = root.querySelector('[data-surcharge-modal]');
const typeModal = root.querySelector('[data-type-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 = '新增报价规则'; };
const closeType = () => { typeModal.classList.remove('is-open'); };
root.querySelector('[data-surcharge-open]')?.addEventListener('click', () => { close(); modal.classList.add('is-open'); root.querySelector('#surcharge-form-courier').focus(); });
root.querySelector('[data-type-manage]')?.addEventListener('click', () => { typeModal.classList.add('is-open'); root.querySelector('#new-surcharge-type').focus(); });
root.querySelectorAll('[data-surcharge-close]').forEach((button) => button.addEventListener('click', close));
root.querySelectorAll('[data-type-close]').forEach((button) => button.addEventListener('click', closeType));
modal.addEventListener('click', (event) => { if (event.target === modal) close(); });
typeModal.addEventListener('click', (event) => { if (event.target === typeModal) closeType(); });
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] || ''; });
@ -114,7 +140,7 @@
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(); });
document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (modal.classList.contains('is-open')) close(); if (typeModal.classList.contains('is-open')) closeType(); });
})();
</script>
</section>

View File

@ -4,6 +4,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 App\Http\Controllers\Business\CourierSurchargeTypes;
use Illuminate\Support\Facades\Route;
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
@ -135,6 +136,8 @@ Route::prefix('workspace/courier-payables/courier-surcharge')
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::post('types', [CourierSurchargeTypes::class, 'store'])->name('types.store');
Route::post('types/{type}/status', [CourierSurchargeTypes::class, 'setStatus'])->name('types.status');
});
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')

View File

@ -3,7 +3,9 @@
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\CourierSurchargeType;
use App\Models\Business\CourierSurchargeQuote;
use App\Business\CourierSurchargeTypeRepository;
use Tests\Feature\FeatureTestCase;
class CourierSurchargePageTest extends FeatureTestCase
@ -22,6 +24,9 @@ class CourierSurchargePageTest extends FeatureTestCase
->assertSeeText('附加费报价维护')
->assertSeeText('正式维护数据')
->assertSeeText('报价规则列表')
->assertSeeText('管理附加费类型')
->assertSeeText('类型列表')
->assertSeeText('进仓费')
->assertSeeText('新增报价规则')
->assertSeeText('结算方式')
->assertSeeText('计价标准')
@ -42,6 +47,78 @@ class CourierSurchargePageTest extends FeatureTestCase
->assertSeeText('后续结算页面将按启用中的规则计算');
}
public function test_courier_surcharge_type_can_be_created_and_duplicate_is_rejected(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs();
$this->post(route('business.courier-surcharge.types.store'), ['name' => '偏远地区费'])
->assertRedirect();
$this->assertDatabaseHas('business_courier_surcharge_types', [
'company_id' => company_id(),
'name' => '偏远地区费',
'status' => CourierSurchargeType::STATUS_ACTIVE,
]);
$this->withExceptionHandling()
->from(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge']))
->post(route('business.courier-surcharge.types.store'), ['name' => '偏远地区费'])
->assertRedirect()
->assertSessionHasErrors('name');
}
public function test_inactive_type_is_rejected_for_new_quotes_but_keeps_historical_value(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs();
$type = app(CourierSurchargeTypeRepository::class)->all()->firstWhere('name', '超长费');
$this->assertNotNull($type);
$quote = $this->createQuote();
$this->post(route('business.courier-surcharge.types.status', ['type' => $type->id]), [
'status' => CourierSurchargeType::STATUS_INACTIVE,
])->assertRedirect();
$this->withExceptionHandling()->post(route('business.courier-surcharge.store'), [
'courier' => '停用类型测试快递',
'scope' => '自有快递',
'reconciler' => '张会计',
'types' => ['超长费'],
'mode' => '固定单价',
'standard' => '1 元/单',
'condition' => '测试',
'effective_at' => '2026-09-02',
])->assertSessionHasErrors('types.0');
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge']))
->assertOk()
->assertSeeText($quote->courier)
->assertSeeText('超长费')
->assertSeeText('管理附加费类型');
$page = $this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge']))
->assertOk();
$this->assertStringNotContainsString('value="超长费"><span>超长费</span>', $page->getContent());
}
public function test_type_status_endpoint_rejects_another_company_type(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$otherType = CourierSurchargeType::withoutGlobalScopes()->create([
'company_id' => company_id() + 999,
'name' => '其他公司类型',
'status' => CourierSurchargeType::STATUS_ACTIVE,
'sort_order' => 99,
]);
$this->loginAs();
$this->withExceptionHandling()->post(route('business.courier-surcharge.types.status', ['type' => $otherType->id]), [
'status' => CourierSurchargeType::STATUS_INACTIVE,
])->assertNotFound();
}
public function test_courier_surcharge_page_filters_persisted_rules(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());