附加费类型字典自维护(含进仓费) #98
@ -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(),
|
||||
|
||||
154
app/Business/CourierSurchargeTypeRepository.php
Normal file
154
app/Business/CourierSurchargeTypeRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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'],
|
||||
|
||||
58
app/Http/Controllers/Business/CourierSurchargeTypes.php
Normal file
58
app/Http/Controllers/Business/CourierSurchargeTypes.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
35
app/Models/Business/CourierSurchargeType.php
Normal file
35
app/Models/Business/CourierSurchargeType.php
Normal 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 ? '已启用' : '已停用';
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
{
|
||||
// 类型字典不做物理删除,避免回滚迁移时破坏已建立的业务引用。
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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());
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user