新增快递附加费操作日志 #227
148
app/Business/CourierSurchargeAuditLogRepository.php
Normal file
148
app/Business/CourierSurchargeAuditLogRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
44
app/Models/Business/CourierSurchargeAuditLog.php
Normal file
44
app/Models/Business/CourierSurchargeAuditLog.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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([
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user