feat: 快递变价指定发起和审核人员 (#1167)
This commit is contained in:
parent
d2b3dc3f60
commit
9dbabead87
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
'当前账号没有报价签署管理权限。',
|
||||
);
|
||||
|
||||
@ -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', '报价变价发起和审核人员已保存,立即对未完成变价生效。');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' => '当前状态不允许继续修改。']);
|
||||
}
|
||||
|
||||
143
app/Support/CourierQuoteWorkflowPermissions.php
Normal file
143
app/Support/CourierQuoteWorkflowPermissions.php
Normal 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' => '钉钉花名册暂时无法核验,请稍后重试。']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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' => '快递开通后才能发起变价。']);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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}"]`);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
200
tests/Feature/Business/CourierQuoteWorkflowPermissionsTest.php
Normal file
200
tests/Feature/Business/CourierQuoteWorkflowPermissionsTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user