新增快递附加费操作日志 #227

Merged
zhoushengchen merged 2 commits from feature/zhoushengchen-courier-surcharge-audit into main 2026-09-21 10:42:13 +08:00
8 changed files with 569 additions and 27 deletions

View File

@ -0,0 +1,148 @@
<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeAuditLog;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
/**
* 快递附加费维护审计日志的固定入口。
*
* 日志只保存当前公司、操作人和报价/类型的安全字段快照,不保存密码、
* Token、连接串或原始请求内容。
*/
class CourierSurchargeAuditLogRepository
{
/** @var array<string,string> */
private const FIELD_LABELS = [
'courier' => '快递名称',
'scope' => '快递归属',
'reconciler' => '对账人',
'types' => '附加费类型',
'mode' => '结算方式',
'standard' => '计价标准',
'condition' => '生效条件',
'effective_at' => '生效日期',
'remark' => '备注',
'status' => '状态',
'name' => '类型名称',
];
/** @var array<string,string> */
private const ACTION_LABELS = [
CourierSurchargeAuditLog::ACTION_CREATED => '新增',
CourierSurchargeAuditLog::ACTION_UPDATED => '更改',
CourierSurchargeAuditLog::ACTION_ACTIVATED => '启用',
CourierSurchargeAuditLog::ACTION_DEACTIVATED => '停用',
];
/** @var array<string,string> */
private const TARGET_LABELS = [
CourierSurchargeAuditLog::TARGET_QUOTE => '报价规则',
CourierSurchargeAuditLog::TARGET_TYPE => '附加费类型',
];
public function ready(): bool
{
try {
return Schema::hasTable((new CourierSurchargeAuditLog())->getTable());
} catch (QueryException) {
return false;
}
}
/** @param array<string,mixed> $changes */
public function record(
string $action,
string $targetType,
?int $targetId,
string $targetLabel,
array $changes,
int $actorId,
): CourierSurchargeAuditLog {
abort_unless($this->ready(), 503, '附加费操作日志数据表尚未完成迁移。');
abort_unless(array_key_exists($action, self::ACTION_LABELS), 422, '附加费日志动作不合法。');
abort_unless(array_key_exists($targetType, self::TARGET_LABELS), 422, '附加费日志对象不合法。');
return CourierSurchargeAuditLog::query()->create([
'company_id' => company_id(),
'actor_id' => $actorId ?: null,
'action' => $action,
'target_type' => $targetType,
'target_id' => $targetId,
'target_label' => mb_substr(trim($targetLabel), 0, 191),
'changes' => $changes,
]);
}
/**
* @return Collection<int,array<string,mixed>>
*/
public function recent(int $limit = 50): Collection
{
if (! $this->ready()) {
return collect();
}
try {
return CourierSurchargeAuditLog::query()
->with('actor')
->orderByDesc('id')
->limit(max(1, min($limit, 200)))
->get()
->map(fn (CourierSurchargeAuditLog $log): array => [
'id' => $log->id,
'occurred_at' => optional($log->created_at)->format('Y-m-d H:i:s') ?: '',
'actor_name' => $log->actor?->name ?: '系统',
'action' => $log->action,
'action_label' => self::ACTION_LABELS[$log->action] ?? $log->action,
'target_type_label' => self::TARGET_LABELS[$log->target_type] ?? $log->target_type,
'target_label' => $log->target_label,
'changes_label' => $this->changesLabel($log),
])
->values();
} catch (QueryException) {
return collect();
}
}
private function changesLabel(CourierSurchargeAuditLog $log): string
{
$changes = (array) $log->changes;
if ($log->action === CourierSurchargeAuditLog::ACTION_CREATED) {
return '新增记录';
}
if (in_array($log->action, [CourierSurchargeAuditLog::ACTION_ACTIVATED, CourierSurchargeAuditLog::ACTION_DEACTIVATED], true)) {
$before = data_get($changes, 'before.status');
$after = data_get($changes, 'after.status');
return sprintf('状态:%s → %s', $before ?: '—', $after ?: '—');
}
$before = (array) ($changes['before'] ?? []);
$after = (array) ($changes['after'] ?? []);
$parts = [];
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $field) {
$old = $this->valueLabel($before[$field] ?? null);
$new = $this->valueLabel($after[$field] ?? null);
if ($old !== $new) {
$parts[] = sprintf('%s:%s → %s', self::FIELD_LABELS[$field] ?? $field, $old, $new);
}
}
return $parts === [] ? '未改变字段' : implode(';', $parts);
}
private function valueLabel(mixed $value): string
{
if (is_array($value)) {
return implode('、', array_map(static fn (mixed $item): string => (string) $item, $value));
}
$value = trim((string) $value);
return $value === '' ? '—' : $value;
}
}

View File

@ -2,10 +2,12 @@
namespace App\Business;
use App\Models\Business\CourierSurchargeAuditLog;
use App\Models\Business\CourierSurchargeQuote;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
@ -28,7 +30,10 @@ class CourierSurchargeQuoteRepository
public const SCOPES = ['自有快递', '代发仓'];
public function __construct(private readonly CourierSurchargeTypeRepository $types)
public function __construct(
private readonly CourierSurchargeTypeRepository $types,
private readonly CourierSurchargeAuditLogRepository $audits,
)
{
}
@ -154,27 +159,102 @@ class CourierSurchargeQuoteRepository
/** @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,
]);
return DB::transaction(function () use ($data, $userId): CourierSurchargeQuote {
$quote = CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
'created_by' => $userId,
]);
$this->audits->record(
CourierSurchargeAuditLog::ACTION_CREATED,
CourierSurchargeAuditLog::TARGET_QUOTE,
(int) $quote->id,
$this->targetLabel($quote),
['after' => $this->snapshot($quote)],
$userId,
);
return $quote;
});
}
/** @param array<string,mixed> $data */
public function update(CourierSurchargeQuote $quote, array $data, int $userId): CourierSurchargeQuote
{
$quote->update($this->attributes($data, $userId));
return DB::transaction(function () use ($quote, $data, $userId): CourierSurchargeQuote {
$before = $this->snapshot($quote);
$quote->update($this->attributes($data, $userId));
$fresh = $quote->fresh();
return $quote->fresh();
$this->audits->record(
CourierSurchargeAuditLog::ACTION_UPDATED,
CourierSurchargeAuditLog::TARGET_QUOTE,
(int) $fresh->id,
$this->targetLabel($fresh),
['before' => $before, 'after' => $this->snapshot($fresh)],
$userId,
);
return $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 DB::transaction(function () use ($quote, $status, $userId): CourierSurchargeQuote {
$oldStatus = $quote->status;
$quote->forceFill(['status' => $status, 'updated_by' => $userId])->save();
$fresh = $quote->fresh();
return $quote->fresh();
if ($oldStatus !== $status) {
$this->audits->record(
$status === CourierSurchargeQuote::STATUS_ACTIVE
? CourierSurchargeAuditLog::ACTION_ACTIVATED
: CourierSurchargeAuditLog::ACTION_DEACTIVATED,
CourierSurchargeAuditLog::TARGET_QUOTE,
(int) $fresh->id,
$this->targetLabel($fresh),
[
'before' => ['status' => $this->statusLabel($oldStatus)],
'after' => ['status' => $this->statusLabel($status)],
],
$userId,
);
}
return $fresh;
});
}
private function targetLabel(CourierSurchargeQuote $quote): string
{
$type = $quote->typeLabel();
return trim(sprintf('%s%s', $quote->courier, $type === '' ? '' : ' · ' . $type));
}
/** @return array<string,mixed> */
private function snapshot(CourierSurchargeQuote $quote): array
{
return [
'courier' => $quote->courier,
'scope' => $quote->scope,
'reconciler' => $quote->reconciler,
'types' => array_values((array) $quote->types),
'mode' => $quote->mode,
'standard' => $quote->standard,
'condition' => $quote->condition,
'effective_at' => optional($quote->effective_at)->format('Y-m-d'),
'status' => $this->statusLabel($quote->status),
'remark' => $quote->remark,
];
}
private function statusLabel(?string $status): string
{
return $status === CourierSurchargeQuote::STATUS_ACTIVE ? '已启用' : '已停用';
}
/** @param array<string,mixed> $data */

View File

@ -2,10 +2,12 @@
namespace App\Business;
use App\Models\Business\CourierSurchargeAuditLog;
use App\Models\Business\CourierSurchargeQuote;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
@ -17,6 +19,10 @@ use Illuminate\Validation\ValidationException;
*/
class CourierSurchargeTypeRepository
{
public function __construct(private readonly CourierSurchargeAuditLogRepository $audits)
{
}
/** @var list<string> */
public const DEFAULT_NAMES = [
'退回费', '拦截费', '改地址', '异形件', '超长费',
@ -89,14 +95,27 @@ class CourierSurchargeTypeRepository
$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,
]);
return DB::transaction(function () use ($name, $sortOrder, $userId): CourierSurchargeType {
$type = CourierSurchargeType::query()->create([
'company_id' => company_id(),
'name' => $name,
'status' => CourierSurchargeType::STATUS_ACTIVE,
'sort_order' => $sortOrder,
'created_by' => $userId,
'updated_by' => $userId,
]);
$this->audits->record(
CourierSurchargeAuditLog::ACTION_CREATED,
CourierSurchargeAuditLog::TARGET_TYPE,
(int) $type->id,
$type->name,
['after' => ['name' => $type->name, 'status' => '已启用']],
$userId,
);
return $type;
});
} catch (QueryException $exception) {
if ((int) $exception->getCode() === 23000) {
throw ValidationException::withMessages(['name' => '当前公司已有同名附加费类型。']);
@ -110,12 +129,37 @@ class CourierSurchargeTypeRepository
{
abort_unless(in_array($status, [CourierSurchargeType::STATUS_ACTIVE, CourierSurchargeType::STATUS_INACTIVE], true), 422, '附加费类型状态不合法。');
$type->forceFill([
'status' => $status,
'updated_by' => $userId,
])->save();
return DB::transaction(function () use ($type, $status, $userId): CourierSurchargeType {
$oldStatus = $type->status;
$type->forceFill([
'status' => $status,
'updated_by' => $userId,
])->save();
$fresh = $type->fresh();
return $type->fresh();
if ($oldStatus !== $status) {
$this->audits->record(
$status === CourierSurchargeType::STATUS_ACTIVE
? CourierSurchargeAuditLog::ACTION_ACTIVATED
: CourierSurchargeAuditLog::ACTION_DEACTIVATED,
CourierSurchargeAuditLog::TARGET_TYPE,
(int) $fresh->id,
$fresh->name,
[
'before' => ['status' => $this->statusLabel($oldStatus)],
'after' => ['status' => $this->statusLabel($status)],
],
$userId,
);
}
return $fresh;
});
}
private function statusLabel(?string $status): string
{
return $status === CourierSurchargeType::STATUS_ACTIVE ? '已启用' : '已停用';
}
private function ensureDefaults(int $companyId): void

View File

@ -16,6 +16,7 @@ use App\Models\Business\CourierQuoteVersion;
use App\Models\Business\CourierSigningSubject;
use App\Models\Business\InstallationDetail;
use App\Models\Business\StoreReceivingAccount;
use App\Business\CourierSurchargeAuditLogRepository;
use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeTypeRepository;
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
@ -95,6 +96,7 @@ class Pages extends Controller
private readonly UpstreamQueryCacheFactory $upstreamCaches,
private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes,
private readonly CourierSurchargeTypeRepository $courierSurchargeTypes,
private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits,
private readonly JushuitanShopBalanceRepository $jushuitanShopBalances,
) {
$this->middleware('auth');
@ -982,6 +984,7 @@ class Pages extends Controller
'status' => $clean(request('status', ''), 20),
];
$data = $this->courierSurchargeQuotes->pageData($filters);
$auditReady = $this->courierSurchargeAudits->ready();
return [
'view' => 'business.pages.courier-payables.courier-surcharge',
@ -989,8 +992,11 @@ class Pages extends Controller
'courierSurchargeFilters' => $filters,
'courierSurchargeSummary' => $data['summary'],
'courierSurchargeOptions' => $data['options'],
'courierSurchargeReady' => $data['ready'],
'courierSurchargeReady' => $data['ready'] && $auditReady,
'courierSurchargeDataReady' => $data['ready'],
'courierSurchargeAuditReady' => $auditReady,
'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()),
'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(),
];
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class CourierSurchargeAuditLog extends Model
{
public const ACTION_CREATED = 'created';
public const ACTION_UPDATED = 'updated';
public const ACTION_ACTIVATED = 'activated';
public const ACTION_DEACTIVATED = 'deactivated';
public const TARGET_QUOTE = 'quote';
public const TARGET_TYPE = 'type';
protected $table = 'business_courier_surcharge_audit_logs';
protected $fillable = [
'company_id',
'actor_id',
'action',
'target_type',
'target_id',
'target_label',
'changes',
];
protected $casts = [
'company_id' => 'integer',
'actor_id' => 'integer',
'target_id' => 'integer',
'changes' => 'array',
];
public function actor()
{
return $this->belongsTo(user_model_class(), 'actor_id');
}
}

View File

@ -0,0 +1,36 @@
<?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_audit_logs')) {
return;
}
Schema::create('business_courier_surcharge_audit_logs', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('actor_id')->nullable();
$table->string('action', 32);
$table->string('target_type', 32);
$table->unsignedInteger('target_id')->nullable();
$table->string('target_label', 191);
$table->text('changes')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'created_at'], 'courier_surcharge_audit_company_created_index');
$table->index(['company_id', 'target_type', 'target_id'], 'courier_surcharge_audit_target_index');
});
}
public function down(): void
{
Schema::dropIfExists('business_courier_surcharge_audit_logs');
}
};

View File

@ -4,7 +4,10 @@
$summary = $courierSurchargeSummary ?? [];
$options = $courierSurchargeOptions ?? [];
$ready = (bool) ($courierSurchargeReady ?? false);
$dataReady = (bool) ($courierSurchargeDataReady ?? $ready);
$auditReady = (bool) ($courierSurchargeAuditReady ?? $ready);
$typeUsage = $courierSurchargeTypeUsage ?? [];
$auditLogs = $courierSurchargeAuditLogs ?? collect();
$statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive';
$columns = [
['key' => 'id', 'label' => '规则编号', 'fixed' => true, 'exportable' => true],
@ -49,6 +52,13 @@
.sm-actions { display:flex; flex-wrap:wrap; 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-button:disabled { cursor:not-allowed; opacity:.55; }
.sm-table-wrap { max-height:min(62vh,42rem); overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable both-edges; } .sm-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.75rem; } .sm-table th,.sm-table td { min-width:7.5rem; 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:2; background:var(--sm-soft); font-weight:700; } .sm-table [data-column-key="id"] { position:sticky; left:0; z-index:3; background:#fff; } .sm-table th[data-column-key="id"] { z-index:5; background:var(--sm-soft); } .sm-table [data-column-key="actions"] { position:sticky; right:0; z-index:3; background:#fff; box-shadow:-1px 0 0 var(--sm-border); } .sm-table th[data-column-key="actions"] { z-index:5; background:var(--sm-soft); } .sm-table tr:hover [data-column-key="id"],.sm-table tr:hover [data-column-key="actions"] { background:#fcfaf4; } .sm-table th[data-fixed="true"] { cursor:default; } .sm-table th[data-column-header]:not([data-fixed="true"]) { cursor:grab; } .sm-table th[data-column-header]:not([data-fixed="true"]):active { cursor:grabbing; } .sm-table th.is-dragging { opacity:.55; } .sm-table th.is-drop-target { box-shadow:inset 3px 0 0 var(--sm-accent); } .sm-table th .sm-table__header { display:inline-flex; align-items:center; gap:.35rem; } .sm-table th .sm-table__drag { color:var(--sm-muted); font-size:1rem; } .sm-table [data-column-key].is-column-hidden { display:none; } .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-table-meta { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.6rem 1rem; padding:.65rem 1.15rem; border-bottom:1px solid var(--sm-border); background:#fffdf8; color:var(--sm-muted); font-size:.72rem; }
.sm-audit-table-wrap { max-height:24rem; overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable both-edges; }
.sm-audit-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.75rem; }
.sm-audit-table th,.sm-audit-table td { min-width:8rem; padding:.6rem .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-audit-table th { background:var(--sm-soft); font-weight:700; }
.sm-audit-table td:nth-child(5) { min-width:24rem; white-space:normal; line-height:1.5; }
.sm-audit-action { display:inline-flex; align-items:center; padding:.15rem .45rem; border-radius:999px; background:var(--sm-soft); color:var(--sm-accent); font-size:.7rem; font-weight:700; }
.sm-audit-empty { padding:1.8rem 1rem; text-align:center; color:var(--sm-muted); }
.sm-column-picker { position:relative; } .sm-column-picker__panel { position:absolute; right:0; z-index:20; display:grid; width:min(22rem,calc(100vw - 2rem)); gap:.65rem; margin-top:.35rem; padding:.8rem; border:1px solid var(--sm-border); border-radius:.55rem; background:#fff; box-shadow:0 4px 8px rgba(23,23,23,.1); } .sm-column-picker__panel[hidden] { display:none; } .sm-column-picker__intro { display:grid; gap:.2rem; color:var(--sm-muted); font-size:.7rem; line-height:1.45; } .sm-column-picker__intro strong { color:var(--sm-ink); font-size:.78rem; } .sm-visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } .sm-column-picker__list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.35rem; max-height:15rem; overflow:auto; } .sm-column-picker__option { display:flex; align-items:center; gap:.35rem; min-width:0; padding:.35rem .4rem; border:1px solid transparent; border-radius:.35rem; color:var(--sm-ink); font-size:.7rem; cursor:grab; } .sm-column-picker__option:hover,.sm-column-picker__option.is-drop-target { border-color:var(--sm-border); background:#fffdf8; } .sm-column-picker__option.is-fixed { cursor:default; } .sm-column-picker__option.is-dragging { opacity:.55; } .sm-column-picker__option input { flex:none; accent-color:var(--sm-accent); } .sm-column-picker__option .material-icons-outlined { color:var(--sm-muted); font-size:.95rem; } .sm-column-picker__foot { display:flex; justify-content:flex-end; padding-top:.35rem; border-top:1px solid var(--sm-border); }
.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; }
@ -58,7 +68,7 @@
@media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form,.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>
<div class="sm-notice" role="note"><span class="material-icons-outlined" aria-hidden="true">{{ $ready ? 'verified' : 'info' }}</span><div><strong>{{ $ready ? '正式维护数据' : '数据表尚未就绪' }}</strong> {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : ($dataReady && !$auditReady ? '操作日志表尚未完成迁移;现有报价仍可查看,新增、编辑和启停暂时禁用,避免产生无日志修改。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。') }}</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>
@ -110,6 +120,32 @@
<div class="sm-rule-note"><strong>维护建议:</strong>固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。</div>
</div>
<div class="sm-card" aria-label="附加费维护操作日志">
<div class="sm-card__head"><div class="sm-card__title"><span class="material-icons-outlined" aria-hidden="true">history</span>操作日志</div><span class="sm-card__hint">记录当前公司最近 50 条新增、更改、启用和停用操作</span></div>
@if (!$auditReady)
<div class="sm-audit-empty">操作日志表尚未就绪,完成迁移后将从首次新操作开始记录。</div>
@elseif ($auditLogs->isEmpty())
<div class="sm-audit-empty">暂无操作日志。新增、编辑或切换报价规则/附加费类型状态后,记录会显示在这里。</div>
@else
<div class="sm-audit-table-wrap">
<table class="sm-audit-table">
<thead><tr><th scope="col">操作时间</th><th scope="col">操作人</th><th scope="col">动作</th><th scope="col">对象</th><th scope="col">变更内容</th></tr></thead>
<tbody>
@foreach ($auditLogs as $log)
<tr>
<td>{{ $log['occurred_at'] }}</td>
<td>{{ $log['actor_name'] }}</td>
<td><span class="sm-audit-action">{{ $log['action_label'] }}</span></td>
<td>{{ $log['target_type_label'] }} · {{ $log['target_label'] }}</td>
<td>{{ $log['changes_label'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</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>

View File

@ -2,10 +2,12 @@
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\CourierSurchargeType;
use App\Models\Business\CourierSurchargeQuote;
use App\Business\CourierSurchargeTypeRepository;
use App\Models\Auth\Permission;
use App\Models\Business\CourierSurchargeAuditLog;
use App\Models\Business\CourierSurchargeQuote;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Support\Facades\Schema;
use Tests\Feature\FeatureTestCase;
class CourierSurchargePageTest extends FeatureTestCase
@ -211,6 +213,152 @@ class CourierSurchargePageTest extends FeatureTestCase
$this->assertSame(CourierSurchargeQuote::STATUS_INACTIVE, $quote->fresh()->status);
}
public function test_quote_operations_are_recorded_with_actor_and_changed_fields(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs();
$payload = [
'courier' => '日志测试快递',
'scope' => '自有快递',
'reconciler' => '周会计',
'types' => ['进仓费'],
'mode' => '固定单价',
'standard' => '10 元/单',
'condition' => '完成进仓',
'effective_at' => '2026-09-21',
'remark' => '初始标准',
];
$this->post(route('business.courier-surcharge.store'), $payload)->assertRedirect();
$quote = CourierSurchargeQuote::query()->where('courier', '日志测试快递')->firstOrFail();
$created = CourierSurchargeAuditLog::query()
->where('target_type', CourierSurchargeAuditLog::TARGET_QUOTE)
->where('target_id', $quote->id)
->where('action', CourierSurchargeAuditLog::ACTION_CREATED)
->firstOrFail();
$this->assertSame((int) user_id(), (int) $created->actor_id);
$this->assertSame('日志测试快递 · 进仓费', $created->target_label);
$this->assertSame('10 元/单', data_get($created->changes, 'after.standard'));
$this->patch(route('business.courier-surcharge.update', ['quote' => $quote->id]), [
...$payload,
'standard' => '12 元/单',
'remark' => '调整后标准',
])->assertRedirect();
$updated = CourierSurchargeAuditLog::query()
->where('target_type', CourierSurchargeAuditLog::TARGET_QUOTE)
->where('target_id', $quote->id)
->where('action', CourierSurchargeAuditLog::ACTION_UPDATED)
->firstOrFail();
$this->assertSame('10 元/单', data_get($updated->changes, 'before.standard'));
$this->assertSame('12 元/单', data_get($updated->changes, 'after.standard'));
$this->assertSame('初始标准', data_get($updated->changes, 'before.remark'));
$this->assertSame('调整后标准', data_get($updated->changes, 'after.remark'));
$this->post(route('business.courier-surcharge.status', ['quote' => $quote->id]), [
'status' => CourierSurchargeQuote::STATUS_INACTIVE,
])->assertRedirect();
$this->post(route('business.courier-surcharge.status', ['quote' => $quote->id]), [
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
])->assertRedirect();
$this->assertDatabaseHas('business_courier_surcharge_audit_logs', [
'company_id' => company_id(),
'target_type' => CourierSurchargeAuditLog::TARGET_QUOTE,
'target_id' => $quote->id,
'action' => CourierSurchargeAuditLog::ACTION_DEACTIVATED,
]);
$this->assertDatabaseHas('business_courier_surcharge_audit_logs', [
'company_id' => company_id(),
'target_type' => CourierSurchargeAuditLog::TARGET_QUOTE,
'target_id' => $quote->id,
'action' => CourierSurchargeAuditLog::ACTION_ACTIVATED,
]);
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge']))
->assertOk()
->assertSeeText('操作日志')
->assertSeeText('新增')
->assertSeeText('更改')
->assertSeeText('停用')
->assertSeeText('启用')
->assertSeeText($this->user->name)
->assertSeeText('计价标准:10 元/单 → 12 元/单');
}
public function test_type_operations_are_recorded_and_other_company_logs_are_hidden(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs();
$this->post(route('business.courier-surcharge.types.store'), ['name' => '日志专用类型'])
->assertRedirect();
$type = CourierSurchargeType::query()->where('name', '日志专用类型')->firstOrFail();
$this->post(route('business.courier-surcharge.types.status', ['type' => $type->id]), [
'status' => CourierSurchargeType::STATUS_INACTIVE,
])->assertRedirect();
$this->post(route('business.courier-surcharge.types.status', ['type' => $type->id]), [
'status' => CourierSurchargeType::STATUS_ACTIVE,
])->assertRedirect();
$this->assertSame([
CourierSurchargeAuditLog::ACTION_CREATED,
CourierSurchargeAuditLog::ACTION_DEACTIVATED,
CourierSurchargeAuditLog::ACTION_ACTIVATED,
], CourierSurchargeAuditLog::query()
->where('target_type', CourierSurchargeAuditLog::TARGET_TYPE)
->where('target_id', $type->id)
->orderBy('id')
->pluck('action')
->all());
CourierSurchargeAuditLog::withoutGlobalScopes()->create([
'company_id' => company_id() + 999,
'actor_id' => user_id(),
'action' => CourierSurchargeAuditLog::ACTION_CREATED,
'target_type' => CourierSurchargeAuditLog::TARGET_TYPE,
'target_id' => 999,
'target_label' => '其他公司的私有类型',
'changes' => ['after' => ['name' => '其他公司的私有类型']],
]);
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge']))
->assertOk()
->assertSeeText('附加费类型 · 日志专用类型')
->assertDontSeeText('其他公司的私有类型');
}
public function test_existing_quotes_remain_visible_but_writes_stop_when_audit_table_is_missing(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$quote = $this->createQuote();
Schema::drop('business_courier_surcharge_audit_logs');
$this->loginAs()
->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge']))
->assertOk()
->assertSeeText($quote->courier)
->assertSeeText('操作日志表尚未完成迁移')
->assertSee('data-surcharge-open disabled', false);
$this->withExceptionHandling()->post(route('business.courier-surcharge.store'), [
'courier' => '不应保存的快递',
'scope' => '自有快递',
'reconciler' => '周会计',
'types' => ['进仓费'],
'mode' => '固定单价',
'standard' => '10 元/单',
'condition' => '测试',
'effective_at' => '2026-09-21',
])->assertServiceUnavailable();
$this->assertDatabaseMissing('business_courier_surcharge_quotes', ['courier' => '不应保存的快递']);
}
private function createQuote(): CourierSurchargeQuote
{
return CourierSurchargeQuote::query()->create([