feat: 快递变价指定发起和审核人员 (#1167)

This commit is contained in:
limeng 2026-11-13 19:36:07 +08:00
parent d2b3dc3f60
commit 9dbabead87
15 changed files with 473 additions and 22 deletions

View File

@ -93,6 +93,7 @@ class CourierOnboardings extends Controller
/** 后台变价页:复用入驻报价填写区,以最新版本(或已退回的变价单)为底稿。 */
public function requote(CourierOnboarding $onboarding): View|RedirectResponse
{
app(\App\Support\CourierQuoteWorkflowPermissions::class)->assertInitiator((int) user_id());
$this->ensureReady();
$quoteManagement = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']);
if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) {

View File

@ -6,6 +6,7 @@ use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierStockQuoteEntry;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteChangeService;
use App\Support\CourierQuoteWorkflowPermissions;
use App\Support\CourierOnboardingService;
use App\Support\CourierQuoteSignatureService;
use App\Support\CourierRequoteService;
@ -38,9 +39,14 @@ class CourierQuoteChanges extends Controller
->paginate(20)
->withQueryString();
$quoteReviewer = $this->canReview();
$admin = $this->canAdminister();
$reviewableIds = $changes->filter(fn ($change) => $change->isStockQuote() ? $admin : $quoteReviewer)->pluck('id')->all();
return view('business.pages.courier-payables.quote-change-history', [
'changes' => $changes,
'canReview' => $this->canReview(),
'canReview' => $quoteReviewer || $admin,
'reviewableChangeIds' => $reviewableIds,
'filters' => $filters,
'carrierOptions' => CourierQuoteChange::query()->whereNotNull('carrier_name')->distinct()->orderBy('carrier_name')->pluck('carrier_name')->all(),
'changeTypeOptions' => CourierQuoteChange::query()->whereNotNull('change_type')->distinct()->orderBy('change_type')->pluck('change_type')->all(),
@ -68,6 +74,7 @@ class CourierQuoteChanges extends Controller
public function create(Request $request): View|RedirectResponse
{
app(CourierQuoteWorkflowPermissions::class)->assertInitiator((int) user_id());
$quotes = $this->snapshot->load()['quotes'] ?? [];
abort_if($quotes === [], 422, '尚未导入可用于变更的报价版本。');
@ -99,6 +106,8 @@ class CourierQuoteChanges extends Controller
$requoteDiff = $onboarding !== null
? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload)
: null;
$reviewAllowed = $this->canReview($quoteChange);
$quoteWorkflow = app(CourierQuoteWorkflowPermissions::class)->settings();
return view('business.pages.courier-payables.quote-change-show', [
'change' => $quoteChange,
@ -109,8 +118,11 @@ class CourierQuoteChanges extends Controller
? $this->requotes->reviewWarnings($quoteChange, $requoteDiff)
: [],
'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null,
'canReview' => $this->canReview() && ($quoteChange->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')),
'canManageSignature' => $this->canReview(),
'canReview' => $reviewAllowed && ($quoteChange->created_by !== user_id()
|| (! $quoteWorkflow['configured'] || $quoteChange->isStockQuote()) && config('courier_onboarding.allow_same_reviewer')),
'canManageSignature' => $this->canAdminister() || $reviewAllowed,
'canVoidQuote' => $this->canAdminister(),
'quoteWorkflow' => $quoteWorkflow,
'esignConfigured' => EsignClient::configured(),
'esignEnvironment' => (string) config('services.esign.environment', 'sandbox'),
]);
@ -118,6 +130,9 @@ class CourierQuoteChanges extends Controller
public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse
{
if (! $quoteChange->isStockQuote()) {
app(CourierQuoteWorkflowPermissions::class)->assertInitiator((int) user_id());
}
abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403);
if ($quoteChange->isStockQuote()) {
return redirect()->route('business.courier-stock-quotes.edit', data_get($quoteChange->quote_payload, 'stock_quote_entry_id'));
@ -151,7 +166,7 @@ class CourierQuoteChanges extends Controller
public function approve(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
abort_unless($this->canReview($quoteChange), 403, '当前账号没有报价审核权限。');
if ($quoteChange->isStockQuote()) {
$request->validate(['stock_rules_confirmed' => ['accepted']], ['stock_rules_confirmed.accepted' => '请先核对原报价、计费规则及实际执行日期。']);
}
@ -194,7 +209,7 @@ class CourierQuoteChanges extends Controller
public function batchApprove(Request $request): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
abort_unless($this->canReview() || $this->canAdminister(), 403, '当前账号没有报价审核权限。');
$input = $request->validate([
'ids' => ['required', 'array', 'min:1', 'max:100'],
'ids.*' => ['required', 'integer', 'min:1', 'distinct'],
@ -231,7 +246,7 @@ class CourierQuoteChanges extends Controller
/** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */
public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
abort_unless($this->canAdminister() || $this->canReview($quoteChange), 403, '当前账号没有报价审核权限。');
abort_if($quoteChange->isStockQuote(), 422, '存量报价请先核对历史签署资料,补签通过详情页手动发起。');
$onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name);
abort_if($onboarding === null || $quoteChange->version === null, 404);
@ -244,7 +259,7 @@ class CourierQuoteChanges extends Controller
/** 作废发错的报价版本(未签完的同时撤回 e签宝 签署任务)。 */
public function void(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
abort_unless($this->canAdminister(), 403, '当前账号没有报价审核权限。');
abort_if($quoteChange->version === null, 404);
$data = $request->validate(
['void_reason' => ['required', 'string', 'min:2', 'max:500']],
@ -257,7 +272,7 @@ class CourierQuoteChanges extends Controller
public function returnForChanges(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
abort_unless($this->canReview($quoteChange), 403, '当前账号没有报价审核权限。');
$validated = $request->validate([
'review_note' => ['required', 'string', 'min:2', 'max:1000'],
]);
@ -390,7 +405,13 @@ class CourierQuoteChanges extends Controller
}
}
private function canReview(): bool
private function canReview(?CourierQuoteChange $change = null): bool
{
return $change?->isStockQuote() ? $this->canAdminister()
: app(CourierQuoteWorkflowPermissions::class)->can('reviewers', (int) user_id());
}
private function canAdminister(): bool
{
return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true);
}

View File

@ -6,6 +6,7 @@ use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteSignatureService;
use App\Support\CourierQuoteWorkflowPermissions;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -22,7 +23,7 @@ class CourierQuoteSignatures extends Controller
public function prepare(Request $request, CourierQuoteVersion $quoteVersion): RedirectResponse
{
$this->ensureCanManage();
$this->ensureCanManage($quoteVersion);
abort_unless($quoteVersion->status === 'approved', 422, '只有已审核报价版本可以生成签署文件。');
$data = $request->validate([
'signature_date' => ['required', 'date'],
@ -36,7 +37,7 @@ class CourierQuoteSignatures extends Controller
public function send(Request $request, CourierQuoteSignature $quoteSignature): RedirectResponse
{
$this->ensureCanManage();
$this->ensureCanManage($quoteSignature->version);
abort_unless($quoteSignature->version?->status === 'approved', 422, '只有已审核报价版本可以发起签署。');
abort_unless(EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。');
$data = $request->validate([
@ -64,7 +65,7 @@ class CourierQuoteSignatures extends Controller
public function synchronize(CourierQuoteSignature $quoteSignature): RedirectResponse
{
$this->ensureCanManage();
$this->ensureCanManage($quoteSignature->version);
$signature = $this->service->synchronize($quoteSignature, user_id());
$message = $signature->status === CourierQuoteSignature::STATUS_ARCHIVED
? '签署完成,签署 PDF 和完成证书已私有归档。'
@ -311,10 +312,12 @@ class CourierQuoteSignatures extends Controller
}
}
private function ensureCanManage(): void
private function ensureCanManage(?CourierQuoteVersion $version = null): void
{
abort_unless(
in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true),
in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true)
|| ($version?->change && ! $version->change->isStockQuote()
&& app(CourierQuoteWorkflowPermissions::class)->can('reviewers', (int) user_id())),
403,
'当前账号没有报价签署管理权限。',
);

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\SystemSettings;
use App\Support\CourierOnboardingReviewers;
use App\Support\CourierQuoteWorkflowPermissions;
use App\Utilities\SystemAccess;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@ -21,6 +22,8 @@ class WorkflowReviewers extends Controller
{
return view('system_settings.workflow-reviewers.index', [
'reviewers' => $this->reviewers->settings(), 'history' => $this->reviewers->history(),
'quotePermissions' => app(CourierQuoteWorkflowPermissions::class)->settings(),
'quoteHistory' => app(CourierQuoteWorkflowPermissions::class)->history(),
]);
}
@ -28,7 +31,11 @@ class WorkflowReviewers extends Controller
{
$data = $request->validate(['q' => ['required', 'string', 'min:1', 'max:50']]);
try {
return response()->json(['people' => $this->reviewers->search(trim($data['q']))])->header('Cache-Control', 'no-store');
$people = $request->query('workflow') === 'quote'
? app(CourierQuoteWorkflowPermissions::class)->search(trim($data['q']))
: $this->reviewers->search(trim($data['q']));
return response()->json(['people' => $people])->header('Cache-Control', 'no-store');
} catch (\Throwable $e) {
return response()->json(['message' => '钉钉花名册暂时无法查询,请稍后重试。'], 503)->header('Cache-Control', 'no-store');
}
@ -45,4 +52,17 @@ class WorkflowReviewers extends Controller
$this->reviewers->save($data, user());
return redirect()->route('system-settings.workflow-reviewers.index')->with('success', '审核人员已保存,立即对待审核申请生效。');
}
public function updateQuote(Request $request)
{
$data = $request->validate([
'initiators' => ['required', 'array', 'min:1', 'max:10'],
'initiators.*' => ['required', 'string', 'max:100', 'distinct'],
'reviewers' => ['required', 'array', 'min:1', 'max:10'],
'reviewers.*' => ['required', 'string', 'max:100', 'distinct'],
]);
app(CourierQuoteWorkflowPermissions::class)->save($data, user()->name, (int) user_id());
return redirect()->route('system-settings.workflow-reviewers.index')->with('success', '报价变价发起和审核人员已保存,立即对未完成变价生效。');
}
}

View File

@ -18,6 +18,9 @@ final class CourierQuoteChangeService
public function create(array $data, int $userId): CourierQuoteChange
{
if (($data['change_type'] ?? '') !== '存量报价') {
app(CourierQuoteWorkflowPermissions::class)->assertInitiator($userId);
}
if (str_starts_with($data['source_quote_id'], 'STOCK-') && ($data['change_type'] ?? '') !== '存量报价') {
throw ValidationException::withMessages(['source_quote_id' => '存量报价请先完成首版审核,再从正式报价版本发起变价。']);
}
@ -121,6 +124,7 @@ final class CourierQuoteChangeService
public function returnForChanges(CourierQuoteChange $change, string $note, int $userId): CourierQuoteChange
{
app(CourierQuoteWorkflowPermissions::class)->assertReviewer($change, $userId);
if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) {
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以退回。']);
}
@ -135,6 +139,7 @@ final class CourierQuoteChangeService
public function approve(CourierQuoteChange $change, int $userId, bool $historicalDocumentsConfirmed = false): CourierQuoteChange
{
app(CourierQuoteWorkflowPermissions::class)->assertReviewer($change, $userId);
if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) {
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']);
}
@ -306,6 +311,9 @@ final class CourierQuoteChangeService
private function ensureEditableBy(CourierQuoteChange $change, int $userId): void
{
if (! $change->isStockQuote()) {
app(CourierQuoteWorkflowPermissions::class)->assertInitiator($userId);
}
if (! $change->isEditable()) {
throw ValidationException::withMessages(['status' => '当前状态不允许继续修改。']);
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Support;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\User;
use App\Models\Business\CourierQuoteChange;
use App\Services\Auth\FinanceRoster;
use App\Utilities\SidebarPermissions;
use App\Utilities\SystemAccess;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class CourierQuoteWorkflowPermissions
{
public const KEY = 'workflow.courier_quote_permissions';
public const AUDIT_PREFIX = 'workflow.courier_quote_permission_audit.';
public function __construct(private readonly FinanceRoster $roster)
{
}
public function settings(): array
{
$value = DB::table('settings')->where('company_id', company_id())->where('key', self::KEY)->value('value');
$settings = json_decode($value ?? '', true);
$settings = is_array($settings) ? $settings : [];
return ['configured' => $value !== null] + $settings + ['initiators' => [], 'reviewers' => []];
}
public function history(): array
{
return DB::table('settings')->where('company_id', company_id())->where('key', 'like', self::AUDIT_PREFIX . '%')
->orderByDesc('key')->limit(10)->pluck('value')->map(fn ($value) => json_decode($value, true))->all();
}
public function save(array $data, string $actor, ?int $actorId): void
{
$next = ['initiators' => [], 'reviewers' => []];
foreach (array_keys($next) as $stage) {
$ids = $data[$stage] ?? [];
if (! is_array($ids) || count($ids) < 1 || count($ids) > 10 || count(array_unique($ids)) !== count($ids)) {
throw ValidationException::withMessages([$stage => '每类请选择 1 至 10 名不同人员。']);
}
foreach ($ids as $id) {
$account = $this->account((string) $id);
$person = $account ? $this->eligible((string) $id) : null;
if (! $person) {
throw ValidationException::withMessages([$stage => '所选人员须为在职财务人员,已绑定启用账号并有报价管理页面权限。']);
}
$next[$stage][] = ['dingtalk_user_id' => (string) $id, 'display_name' => $person['display_name']];
}
}
if (count(array_unique(array_merge($data['initiators'], $data['reviewers']))) < 2) {
throw ValidationException::withMessages(['reviewers' => '发起人不能自审,请至少配置两名不同人员。']);
}
DB::transaction(function () use ($next, $actor, $actorId): void {
abort_unless(DB::table('companies')->where('id', company_id())->lockForUpdate()->first(), 404);
$before = $this->settings();
$next['updated_at'] = now()->toIso8601String();
$next['updated_by'] = $actor;
DB::table('settings')->updateOrInsert(['company_id' => company_id(), 'key' => self::KEY], [
'value' => json_encode($next, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
]);
DB::table('settings')->insert(['company_id' => company_id(), 'key' => self::AUDIT_PREFIX . Str::ulid(),
'value' => json_encode(['at' => $next['updated_at'], 'actor_id' => $actorId, 'actor' => $actor,
'before' => $before, 'after' => $next], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]);
});
}
public function search(string $term): array
{
return array_map(function (array $row): array {
$id = (string) $row['flowerNameId'];
$account = $this->account($id);
$eligible = $account ? $this->eligible($id) : null;
return ['id' => $id, 'name' => trim((string) ($row['flowerName'] ?: $row['name'])),
'account' => $account?->name, 'selectable' => $eligible !== null,
'reason' => $eligible ? '可选' : '须在职、绑定启用账号并有报价管理页面权限'];
}, $this->roster->search($term));
}
public function can(string $stage, int $userId): bool
{
$settings = $this->settings();
if (! $settings['configured']) {
$user = User::find($userId);
return $user && ($stage === 'initiators' || in_array(SystemAccess::roleName($user), [SystemAccess::ADMIN, SystemAccess::SUPER_ADMIN], true));
}
$identity = DingTalkIdentity::query()->where('user_id', $userId)->first();
if (! $identity || ! in_array($identity->dingtalk_user_id, array_column($settings[$stage] ?? [], 'dingtalk_user_id'), true)
|| $this->account($identity->dingtalk_user_id)?->id !== $userId) {
return false;
}
return $this->eligible($identity->dingtalk_user_id) !== null;
}
public function assertInitiator(int $userId): void
{
abort_unless($this->can('initiators', $userId), 403, '你不是报价变价的指定发起人员。');
}
public function assertReviewer(CourierQuoteChange $change, int $userId): void
{
abort_unless((int) $change->company_id === (int) company_id(), 403);
$allowed = $change->isStockQuote()
? in_array(SystemAccess::roleName(User::find($userId)), [SystemAccess::ADMIN, SystemAccess::SUPER_ADMIN], true)
: $this->can('reviewers', $userId);
abort_unless($allowed, 403, '你不是该报价变价的指定审核人员。');
if (! $change->isStockQuote() && $this->settings()['configured'] && $change->created_by === $userId) {
throw ValidationException::withMessages(['approval' => '发起人不能审核自己提交的报价变更。']);
}
}
private function account(string $id): ?User
{
$identities = DingTalkIdentity::query()->where('dingtalk_user_id', $id)->with('user')->limit(2)->get();
$user = $identities->count() === 1 ? $identities->first()->user : null;
if (! $user || ! $user->enabled || ! $user->companies()->where('companies.id', company_id())->exists()) {
return null;
}
$keys = SidebarPermissions::allowedKeys($user);
return $keys === null || array_intersect(['courier-payables', 'courier-payables/quote-management'], $keys) ? $user : null;
}
private function eligible(string $id): ?array
{
try {
$person = $this->roster->findEligible($id);
return $person && empty($person['restricted']) ? $person : null;
} catch (\Throwable) {
throw ValidationException::withMessages(['approval' => '钉钉花名册暂时无法核验,请稍后重试。']);
}
}
}

View File

@ -134,6 +134,7 @@ final class CourierRequoteService
*/
public function submit(CourierOnboarding $onboarding, array $data, int $userId): CourierQuoteChange
{
app(CourierQuoteWorkflowPermissions::class)->assertInitiator($userId);
if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) {
throw ValidationException::withMessages(['status' => '快递开通后才能发起变价。']);
}

View File

@ -129,7 +129,7 @@
<section class="quote-change-panel" aria-labelledby="quote-detail-edit-title">
<header class="quote-change-panel__header"><div><div class="quote-change-panel__title" id="quote-detail-edit-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>{{ $change ? '变价明细' : '第三步:编辑新价格' }}</span></div><div class="quote-change-panel__hint">仅新价格可编辑;系统会再次核对原价格,防止过期数据覆盖。</div></div></header>
<div class="quote-change-panel__body">
<div class="quote-change-alert"><span class="material-icons-outlined" aria-hidden="true">verified_user</span><span>保存草稿不会让报价生效。提交审核后草稿将锁定,由另一位管理员审核并生成新版本。</span></div>
<div class="quote-change-alert"><span class="material-icons-outlined" aria-hidden="true">verified_user</span><span>保存草稿不会让报价生效。提交审核后草稿将锁定,由指定审核人员审核并生成新版本,发起人不能自审。</span></div>
<div class="quote-batch-tools" aria-label="批量调整">
<div class="quote-change-field"><label for="batch_mode">批量方式</label><select class="quote-change-control" id="batch_mode" data-batch-mode><option value="add">统一加减金额</option><option value="percent">按比例调整</option><option value="set">统一设为金额</option></select></div>
<div class="quote-change-field"><label for="batch_value">调整值</label><input class="quote-change-control" id="batch_value" type="number" step="0.0001" data-batch-value placeholder="例如 1 或 -0.5"></div>

View File

@ -69,7 +69,7 @@
<div class="quote-history__table-wrap"><table><thead><tr>@if($canReview)<th><input type="checkbox" data-review-all aria-label="全选本页可审核记录"></th>@endif<th>变更单号</th><th>原报价</th><th>快递公司</th><th>变更类型</th><th>明细</th><th>生效日期</th><th>生效方式</th><th>追溯核算</th><th>状态</th><th>新版本</th><th>更新时间</th><th>操作</th></tr></thead><tbody>
@foreach($changes as $change)
@php($badge = match($change->status){'pending_review'=>'pending','approved'=>'approved','returned'=>'returned',default=>''})
<tr>@if($canReview)<td>@if($change->status === 'pending_review' && ($change->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')))<input type="checkbox" name="ids[]" value="{{ $change->id }}" data-review-choice data-review-label="{{ $change->number }} · {{ $change->carrier_name }} · {{ $change->change_type }} · {{ $change->periodLabel() }}" aria-label="选择 {{ $change->number }}">@else<span class="quote-history__hint" title="{{ $change->status === 'pending_review' ? '发起人不能审核自己提交的报价' : '仅待审核记录可勾选' }}">—</span>@endif</td>@endif<td class="quote-history__number">{{ $change->number }}</td><td>{{ $change->source_quote_id }}</td><td>{{ $change->carrier_name }}</td><td>{{ $change->change_type }}</td><td>{{ $change->items->count() }} 项</td><td>{{ $change->effective_date->toDateString() }}</td><td><span class="quote-history__badge @if($change->isRetrospective()) quote-history__badge--pending @endif">{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</span></td><td><span class="quote-history__badge @if($change->recalculation_status==='blocked') quote-history__badge--returned @elseif($change->recalculation_status==='pending') quote-history__badge--pending @endif">{{ $change->recalculationStatusLabel() }}</span></td><td><span class="quote-history__badge @if($badge) quote-history__badge--{{ $badge }} @endif">{{ $change->statusLabel() }}</span></td><td>{{ $change->version?->version_code ?? '—' }}</td><td>{{ $change->updated_at?->format('Y-m-d H:i') }}</td><td><a class="quote-history__link" href="{{ route('business.courier-quote-changes.show',$change) }}">查看</a>@if($change->isEditable() && $change->created_by===user_id()) · <a class="quote-history__link" href="{{ route('business.courier-quote-changes.edit',$change) }}">编辑</a>@endif</td></tr>
<tr>@if($canReview)<td>@if(in_array($change->id, $reviewableChangeIds, true) && $change->status === 'pending_review' && ($change->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')))<input type="checkbox" name="ids[]" value="{{ $change->id }}" data-review-choice data-review-label="{{ $change->number }} · {{ $change->carrier_name }} · {{ $change->change_type }} · {{ $change->periodLabel() }}" aria-label="选择 {{ $change->number }}">@else<span class="quote-history__hint" title="{{ $change->status === 'pending_review' ? '发起人不能审核自己提交的报价' : '仅待审核记录可勾选' }}">—</span>@endif</td>@endif<td class="quote-history__number">{{ $change->number }}</td><td>{{ $change->source_quote_id }}</td><td>{{ $change->carrier_name }}</td><td>{{ $change->change_type }}</td><td>{{ $change->items->count() }} 项</td><td>{{ $change->effective_date->toDateString() }}</td><td><span class="quote-history__badge @if($change->isRetrospective()) quote-history__badge--pending @endif">{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</span></td><td><span class="quote-history__badge @if($change->recalculation_status==='blocked') quote-history__badge--returned @elseif($change->recalculation_status==='pending') quote-history__badge--pending @endif">{{ $change->recalculationStatusLabel() }}</span></td><td><span class="quote-history__badge @if($badge) quote-history__badge--{{ $badge }} @endif">{{ $change->statusLabel() }}</span></td><td>{{ $change->version?->version_code ?? '—' }}</td><td>{{ $change->updated_at?->format('Y-m-d H:i') }}</td><td><a class="quote-history__link" href="{{ route('business.courier-quote-changes.show',$change) }}">查看</a>@if($change->isEditable() && $change->created_by===user_id()) · <a class="quote-history__link" href="{{ route('business.courier-quote-changes.edit',$change) }}">编辑</a>@endif</td></tr>
@endforeach
</tbody></table></div>
@if($canReview)

View File

@ -71,6 +71,9 @@
@endif
</div>
</div>
@if(!$change->isStockQuote() && ($quoteWorkflow['configured'] ?? false))
<div class="quote-review__alert" role="status"><span class="material-icons-outlined" aria-hidden="true">verified_user</span><div>变价发起:{{ implode('、', array_column($quoteWorkflow['initiators'], 'display_name')) }};指定审核:{{ implode('、', array_column($quoteWorkflow['reviewers'], 'display_name')) }}。任意一名审核人通过即可,发起人不能自审。</div></div>
@endif
@if(is_string(session('success')))<div style="margin-bottom:16px;border:1px solid #BAD0AB;border-radius:8px;background:#F3F8EF;padding:10px 12px;color:#40622F" role="status">{{ session('success') }}</div>@endif
@if($errors->any())<div style="margin-bottom:16px;border:1px solid #E6B7B7;border-radius:8px;background:#FFF4F4;padding:10px 12px;color:#8A1515" role="alert">{{ $errors->first() }}</div>@endif
@if($reviewWarnings)<div class="quote-review__alert" role="alert" style="border-color:#E6B7B7;background:#FFF4F4;color:#8A1515"><span class="material-icons-outlined" aria-hidden="true">report</span><div><strong>审核提醒(请重点核对)</strong>@foreach($reviewWarnings as $warning)<br>· {{ $warning }}@endforeach</div></div>@endif
@ -191,7 +194,7 @@
</form>
@endif
@endif
@if($canManageSignature && !$change->version->isVoided())
@if($canVoidQuote && !$change->version->isVoided())
<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-changes.void',$change) }}" style="margin-top:16px;padding-top:14px;border-top:1px dashed #E6DFD0" onsubmit="return confirm('确认作废 {{ $change->version->version_code }}?{{ $quoteSignature && in_array($quoteSignature->status, ['archived','completed'], true) ? '快递已签署,作废后需另发一版正确报价让快递重新签。' : '会同时撤回快递 e签宝 里的待签任务。' }}')">@csrf
<div class="quote-review__field"><label for="void_reason">发错了?作废此版本(填写原因)</label><input class="quote-review__control" id="void_reason" name="void_reason" maxlength="500" placeholder="如:江苏面单多打了一位;执行日期填错" value="{{ old('void_reason') }}" required></div>
<div class="quote-review__form-actions"><span class="quote-review__hint">{{ $quoteSignature && in_array($quoteSignature->status, ['archived','completed'], true) ? '快递已签署:作废只在我方停用此版本,回签单保留,之后请发起一版正确的变价让快递重新签。' : '未签完:作废会撤回快递 e签宝 里的待签任务,此版本不再生效。' }}</span><button class="quote-review__button" type="submit" style="color:#A1281F;border-color:#E6B7B7"><span class="material-icons-outlined" aria-hidden="true">block</span><span>作废此版本</span></button></div>

View File

@ -0,0 +1,47 @@
<section class="mt-5 rounded-xl border border-gray-200 bg-white p-6" aria-label="报价变价发起和审核人员">
<h2 class="text-sm font-semibold mb-3">报价变价发起和审核人员</h2>
<p class="text-sm text-gray-600 mb-4">指定人员发起变价,由任意一名指定审核人通过即可。发起人不能审核自己的变价;该设置不改变快递入驻和存量报价补录流程。</p>
@if(!$quotePermissions['configured'])<p class="text-sm mb-4" role="status">尚未单独配置报价变价人员,当前沿用原有权限;保存后按下方名单执行。</p>@endif
<form method="POST" action="{{ route('system-settings.workflow-reviewers.quote.update') }}" data-reviewers-form data-search-url="{{ route('system-settings.workflow-reviewers.search', ['workflow' => 'quote']) }}" data-reviewer-stages="{{ json_encode([['initiators', '加入发起人员'], ['reviewers', '加入审核人员']]) }}">
@csrf @method('PATCH')
<label for="quote-reviewer-search" class="block text-xs font-semibold mb-2">查找钉钉花名册人员</label>
<div class="flex flex-wrap gap-2 mb-2">
<input id="quote-reviewer-search" data-reviewer-search type="search" maxlength="50" placeholder="输入姓名或花名" class="settings-field-focus rounded-lg border border-gray-300 px-3 py-2 text-sm" autocomplete="off">
<button type="button" data-reviewer-search-button class="settings-primary rounded-lg px-4 py-2 text-sm">查询人员</button>
</div>
<p class="text-xs text-gray-600 mb-3">人员须在职、已绑定启用账号并有报价管理页面权限,无需管理员身份。每类最多 10 人。</p>
<p data-reviewer-search-status role="status" class="text-sm mb-3" aria-live="polite"></p>
<div data-reviewer-results class="mb-5"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 border-t border-gray-200 pt-5">
@foreach(['initiators' => '变价发起人员', 'reviewers' => '变价审核人员'] as $stage => $label)
<div>
<h3 class="text-sm font-semibold mb-2">{{ $label }}</h3>
<ul data-reviewer-selected="{{ $stage }}" class="space-y-2" aria-label="{{ $label }}">
@foreach(old($stage, array_column($quotePermissions[$stage], 'dingtalk_user_id')) as $dingId)
@php($person = collect($quotePermissions[$stage])->firstWhere('dingtalk_user_id', $dingId))
<li class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 px-3 py-2 text-sm">
<input type="hidden" name="{{ $stage }}[]" value="{{ $dingId }}">
<span>{{ $person['display_name'] ?? '所选人员(保存时重新核验)' }}</span>
<button type="button" data-reviewer-remove class="settings-accent" aria-label="移除{{ $person['display_name'] ?? '所选人员' }}">移除</button>
</li>
@endforeach
</ul>
<p class="text-xs text-gray-600 mt-2">{{ $stage === 'reviewers' ? '任意一人通过即可,其余人无需重复审核。' : '可创建、保存和提交本人发起的报价变价。' }}</p>
</div>
@endforeach
</div>
<div class="flex flex-wrap items-center gap-4 mt-6">
<button type="submit" class="settings-primary rounded-lg px-4 py-2 text-sm">保存变价人员</button>
<span class="text-xs text-gray-600">保存后立即对未完成变价生效,已完成的审核记录保持不变。</span>
</div>
</form>
<h3 class="text-sm font-semibold mt-6 mb-3">变价权限变更记录(最近 10 次)</h3>
@forelse($quoteHistory as $entry)
<div class="border-t border-gray-200 py-3 text-sm">
<p>{{ $entry['actor'] }} · {{ \Illuminate\Support\Carbon::parse($entry['at'])->format('Y-m-d H:i') }}</p>
<p class="text-xs text-gray-600 mt-1">发起:{{ implode('、', array_column($entry['after']['initiators'], 'display_name')) }};审核:{{ implode('、', array_column($entry['after']['reviewers'], 'display_name')) }}</p>
</div>
@empty
<p class="text-xs text-gray-600">尚无变更记录。</p>
@endforelse
</section>

View File

@ -4,7 +4,7 @@
(() => {
if (window.caiwuWorkflowReviewers) return;
window.caiwuWorkflowReviewers = true;
const people = new Map();
const peopleByForm = new WeakMap();
let pending;
async function search(form) {
const input = form.querySelector('[data-reviewer-search]');
@ -22,7 +22,8 @@
const response = await fetch(url, {signal:controller.signal, credentials:'same-origin', headers:{Accept:'application/json'}});
if (!response.ok) throw new Error('unavailable');
const data = await response.json();
people.clear();
const people = new Map();
peopleByForm.set(form, people);
for (const person of data.people) {
people.set(person.id, person);
const row = document.createElement('div');
@ -32,7 +33,8 @@
row.append(name);
const actions = document.createElement('div');
actions.className = 'flex gap-3';
for (const [stage, label] of [['first','加入初审'],['final','加入复审']]) {
const stages = form.dataset.reviewerStages ? JSON.parse(form.dataset.reviewerStages) : [['first','加入初审'],['final','加入复审']];
for (const [stage, label] of stages) {
const button = document.createElement('button');
button.type = 'button'; button.textContent = label;
button.dataset.reviewerAdd = stage; button.dataset.reviewerId = person.id;
@ -53,7 +55,7 @@
if (!form) return;
if (button.hasAttribute('data-reviewer-search-button')) { search(form); return; }
if (button.hasAttribute('data-reviewer-remove')) { button.closest('li').remove(); return; }
const person = people.get(button.dataset.reviewerId);
const person = peopleByForm.get(form)?.get(button.dataset.reviewerId);
if (!person?.selectable) return;
const stage = button.dataset.reviewerAdd;
const list = form.querySelector(`[data-reviewer-selected="${stage}"]`);

View File

@ -43,6 +43,7 @@
</div>
</form>
</section>
@include('system_settings.partials.quote-workflow-permissions')
<section class="mt-5 rounded-xl border border-gray-200 bg-white p-6" aria-label="配置变更记录">
<h2 class="text-sm font-semibold mb-3">配置变更记录(最近 10 次)</h2>
@forelse ($history as $entry)

View File

@ -1182,6 +1182,7 @@ Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], functi
Route::get('workflow-reviewers', 'SystemSettings\WorkflowReviewers@index')->name('workflow-reviewers.index');
Route::get('workflow-reviewers/search', 'SystemSettings\WorkflowReviewers@search')->name('workflow-reviewers.search');
Route::patch('workflow-reviewers', 'SystemSettings\WorkflowReviewers@update')->name('workflow-reviewers.update');
Route::patch('workflow-reviewers/quote', 'SystemSettings\WorkflowReviewers@updateQuote')->name('workflow-reviewers.quote.update');
Route::get('sidebar', 'SystemSettings\Sidebar@edit')->name('sidebar.edit');
Route::patch('sidebar', 'SystemSettings\Sidebar@update')->name('sidebar.update');

View File

@ -0,0 +1,200 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\User;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierQuoteChange;
use App\Models\Common\Company;
use App\Services\Auth\FinanceRoster;
use App\Support\CourierQuoteWorkflowPermissions;
use App\Support\CourierOnboardingService;
use App\Support\QuoteManagementSnapshot;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class CourierQuoteWorkflowPermissionsTest extends FeatureTestCase
{
private string $fixturePath;
private array $people;
private bool $eligible = true;
protected function setUp(): void
{
parent::setUp();
$this->mock(FinanceRoster::class, function ($mock): void {
$mock->shouldReceive('findEligible')->andReturnUsing(fn ($id) => $this->eligible ? ['display_name' => $id, 'restricted' => false] : null);
$mock->shouldReceive('search')->andReturn([['flowerNameId' => 'review-one', 'name' => '审核一', 'flowerName' => '审核一']]);
});
$this->people = [];
foreach (['init-one', 'init-two', 'review-one', 'review-two'] as $id) {
$person = User::create(['name' => $id, 'email' => $id . '@example.test', 'password' => 'password',
'locale' => 'zh-CN', 'enabled' => true, 'landing_page' => 'dashboard', 'created_from' => 'core::test']);
$person->companies()->attach($this->company->id);
$person->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
$person->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
DingTalkIdentity::create(['user_id' => $person->id, 'union_id' => 'union-' . $id, 'dingtalk_user_id' => $id, 'bound_at' => now()]);
$this->people[$id] = $person;
}
$this->fixturePath = storage_path('framework/testing/quote-permissions-' . uniqid() . '.json');
file_put_contents($this->fixturePath, json_encode(['sourcePath' => 'private/quote.xlsx', 'sheets' => [[
'index' => 0, 'name' => '测试快递报价', 'address' => 'A1:B2',
'values' => [['省份', '面单'], ['江苏', 5]], 'formulas' => [[], []],
]]], JSON_UNESCAPED_UNICODE));
app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot($this->fixturePath));
$this->loginAs();
app(CourierQuoteWorkflowPermissions::class)->save($this->permissionGroups(), $this->user->name, $this->user->id);
}
protected function tearDown(): void
{
@unlink($this->fixturePath);
parent::tearDown();
}
private function permissionGroups(): array
{
return ['initiators' => ['init-one', 'init-two'], 'reviewers' => ['review-one', 'review-two']];
}
private function payload(): array
{
return ['source_quote_id' => 'QB-001', 'change_type' => '费率调整', 'effective_date' => now()->addDay()->toDateString(),
'summary' => '调整江苏面单价格', 'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1,
'region' => '江苏', 'weight_band' => '面单', 'charge_item' => '面单', 'original_amount' => '5', 'new_amount' => '6', 'unit' => '元']]];
}
private function pending(string $initiator = 'init-one'): CourierQuoteChange
{
$this->loginAs($this->people[$initiator], $this->company)
->post(route('business.courier-quote-changes.store'), $this->payload())->assertRedirect();
$change = CourierQuoteChange::latest('id')->firstOrFail();
$this->post(route('business.courier-quote-changes.submit', $change))->assertRedirect();
return $change->fresh();
}
public function test_either_named_regular_reviewer_can_approve_named_initiators_without_admin_grants(): void
{
foreach (['review-one' => 'init-one', 'review-two' => 'init-two'] as $reviewer => $initiator) {
$change = $this->pending($initiator);
$this->loginAs($this->people[$reviewer], $this->company)
->get(route('business.courier-quote-changes.show', $change))->assertOk()->assertSeeText('指定审核')->assertSeeText('审核通过');
$this->post(route('business.courier-quote-changes.approve', $change))->assertRedirect();
$this->assertSame('approved', $change->fresh()->status);
$this->assertSame($this->people[$reviewer]->id, $change->fresh()->version->approved_by);
$this->post(route('business.courier-quote-signatures.prepare', $change->fresh()->version), ['signature_date' => now()->toDateString()])->assertRedirect();
$this->get(route('business.courier-quote-changes.show', $change))->assertOk()->assertSeeText('推送到 E 签宝')->assertDontSeeText('作废此版本');
$this->withExceptionHandling()->post(route('business.courier-quote-changes.void', $change), ['void_reason' => '不应获得作废权限'])->assertForbidden();
$this->withExceptionHandling()->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasErrors('status');
$this->assertSame('user', $this->people[$reviewer]->roles()->first()->name);
}
$this->assertDatabaseCount('business_courier_quote_versions', 2);
}
public function test_admin_and_initiator_cannot_bypass_named_reviewers_or_batch_approval(): void
{
$change = $this->pending();
$this->withExceptionHandling()->post(route('business.courier-quote-changes.approve', $change))->assertForbidden();
$this->loginAs()->post(route('business.courier-quote-changes.approve', $change))->assertForbidden();
$this->get(route('business.courier-quote-changes.index'))->assertOk()->assertDontSee('name="ids[]"', false);
$this->post(route('business.courier-quote-changes.batch-approve'), ['ids' => [$change->id], 'stock_rules_confirmed' => 1])->assertRedirect();
$this->assertSame('pending_review', $change->fresh()->status);
$this->assertDatabaseCount('business_courier_quote_versions', 0);
}
public function test_unlisted_admin_cannot_create_change_and_other_initiator_cannot_edit_owners_draft(): void
{
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $this->payload())->assertForbidden();
$this->loginAs($this->people['init-one'], $this->company)->post(route('business.courier-quote-changes.store'), $this->payload())->assertRedirect();
$change = CourierQuoteChange::firstOrFail();
$this->loginAs($this->people['init-two'], $this->company)->patch(route('business.courier-quote-changes.update', $change), $this->payload())->assertForbidden();
$this->assertSame('draft', $change->fresh()->status);
}
public function test_reviewer_can_return_and_revocation_of_identity_or_configuration_is_immediate(): void
{
$change = $this->pending();
$this->loginAs($this->people['review-one'], $this->company)
->post(route('business.courier-quote-changes.return', $change), ['review_note' => '请核对面单价格'])->assertRedirect();
$this->assertSame('returned', $change->fresh()->status);
$this->loginAs($this->people['init-one'], $this->company)->post(route('business.courier-quote-changes.submit', $change))->assertRedirect();
app(CourierQuoteWorkflowPermissions::class)->save(['initiators' => ['init-one', 'init-two'], 'reviewers' => ['review-two']], 'test admin', $this->user->id);
$this->withExceptionHandling()->loginAs($this->people['review-one'], $this->company)->post(route('business.courier-quote-changes.approve', $change))->assertForbidden();
$this->people['review-two']->update(['enabled' => false]);
$this->assertFalse(app(CourierQuoteWorkflowPermissions::class)->can('reviewers', $this->people['review-two']->id));
$this->loginAs($this->people['review-two'], $this->company)->post(route('business.courier-quote-changes.approve', $change))->assertRedirect();
$this->assertDatabaseCount('business_courier_quote_versions', 0);
}
public function test_self_review_stays_blocked_even_when_both_roles_are_assigned(): void
{
app(CourierQuoteWorkflowPermissions::class)->save(['initiators' => ['init-one'], 'reviewers' => ['init-one', 'review-one']], 'test admin', $this->user->id);
$change = $this->pending();
config(['courier_onboarding.allow_same_reviewer' => true]);
$this->withExceptionHandling()->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasErrors('approval');
$this->assertDatabaseCount('business_courier_quote_versions', 0);
}
public function test_onboarded_courier_requote_entry_and_submission_use_the_same_initiator_list(): void
{
$quote = ['quote_mode' => 'waybill_rate', 'volumetric_rule' => '计泡', 'volumetric_divisor' => 6000,
'quote_effective_date' => now()->addDay()->toDateString(),
'quote_rows' => [['region' => '江苏', 'base' => '3.2', 'rates' => ['0.8']]]];
$onboarding = CourierOnboarding::create([
'company_id' => $this->company->id, 'number' => 'RZ-PERMISSIONS', 'token' => str_repeat('q', 40),
'status' => CourierOnboarding::STATUS_ACTIVATED, 'courier_name' => '权限测试快递',
...app(CourierOnboardingService::class)->quoteAttributes($quote),
]);
$quote['quote_rows'][0]['base'] = '3.5';
$quote['summary'] = '测试变价发起人员权限';
foreach (['review-one', 'review-two'] as $id) {
$this->withExceptionHandling()->loginAs($this->people[$id], $this->company)
->get(route('business.courier-onboardings.requote', $onboarding))->assertForbidden();
$this->post(route('business.courier-onboardings.requote.store', $onboarding), $quote)->assertForbidden();
}
$this->assertDatabaseCount('business_courier_quote_changes', 0);
$this->loginAs($this->people['init-two'], $this->company)
->get(route('business.courier-onboardings.requote', $onboarding))->assertOk();
$this->post(route('business.courier-onboardings.requote.store', $onboarding), $quote)->assertSessionHasNoErrors()->assertRedirect();
$change = CourierQuoteChange::firstOrFail();
$this->assertSame('pending_review', $change->status);
$this->assertSame($this->people['init-two']->id, $change->created_by);
}
public function test_settings_can_be_saved_and_searched_only_by_administrators_and_are_audited(): void
{
$this->loginAs()->get(route('system-settings.workflow-reviewers.index'))->assertOk()->assertSeeText('报价变价发起和审核人员');
$this->patch(route('system-settings.workflow-reviewers.quote.update'), $this->permissionGroups())->assertRedirect();
$this->assertCount(2, app(CourierQuoteWorkflowPermissions::class)->history());
$this->getJson(route('system-settings.workflow-reviewers.search', ['q' => '审核', 'workflow' => 'quote']))->assertOk()->assertJsonPath('people.0.selectable', true);
$this->withExceptionHandling()->loginAs($this->people['review-one'], $this->company)
->patch(route('system-settings.workflow-reviewers.quote.update'), $this->permissionGroups())->assertForbidden();
$this->getJson(route('system-settings.workflow-reviewers.search', ['q' => '审核', 'workflow' => 'quote']))->assertForbidden();
}
public function test_disabled_departed_unbound_and_cross_company_candidates_fail_without_writing(): void
{
$this->eligible = false;
$this->withExceptionHandling()->loginAs()->patch(route('system-settings.workflow-reviewers.quote.update'), $this->permissionGroups())->assertSessionHasErrors('initiators');
$this->eligible = true;
$this->patch(route('system-settings.workflow-reviewers.quote.update'), ['initiators' => ['init-one'], 'reviewers' => ['unbound']])->assertSessionHasErrors('reviewers');
$this->patch(route('system-settings.workflow-reviewers.quote.update'), ['initiators' => ['init-one'], 'reviewers' => ['init-one']])->assertSessionHasErrors('reviewers');
$other = Company::create(['enabled' => true]);
$other->makeCurrent();
$permissions = app(CourierQuoteWorkflowPermissions::class);
$this->assertFalse($permissions->settings()['configured']);
try {
$permissions->save($this->permissionGroups(), 'other admin', null);
$this->fail('Cross-company people must not be assigned');
} catch (\Illuminate\Validation\ValidationException $e) {
$this->assertArrayHasKey('initiators', $e->errors());
}
$this->assertCount(0, $permissions->history());
$this->company->makeCurrent();
$this->assertCount(1, $permissions->history());
}
}