feat(快递入驻): 配置钉钉初复审人员,复审任一人通过

This commit is contained in:
limeng 2026-10-15 13:51:22 +08:00
parent 72b3f43da5
commit 5fd63d6dd0
17 changed files with 777 additions and 44 deletions

View File

@ -6,6 +6,7 @@ use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingEvent;
use App\Models\Business\CourierOnboardingFile;
use App\Support\CourierOnboardingService;
use App\Support\CourierOnboardingReviewers;
use App\Support\QrCode;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
@ -68,6 +69,9 @@ class CourierOnboardings extends Controller
report($exception);
}
$onboarding->load(['files', 'events.actor', 'quoteVersion.signature']);
$reviewers = app(CourierOnboardingReviewers::class);
$reviewAccess = in_array($onboarding->status, ['submitted', 'first_approved', 'returned'], true)
? $reviewers->access($onboarding, (int) user_id()) : ['allowed' => false, 'message' => '当前申请无需审核。'];
return view('business.pages.courier-payables.courier-onboarding-show', [
'onboarding' => $onboarding,
@ -76,9 +80,10 @@ class CourierOnboardings extends Controller
'signedAgreement' => $this->service->signedAgreement($onboarding),
'quoteSignature' => $onboarding->quoteSignature(),
'esignReady' => $this->service->esignReady(),
'canFinalReview' => $this->isAdmin() && (
(int) $onboarding->first_reviewed_by !== (int) user_id() || config('courier_onboarding.allow_same_reviewer')
),
'canFirstReview' => $reviewAccess['allowed'],
'canFinalReview' => $reviewAccess['allowed'],
'reviewAccess' => $reviewAccess,
'reviewerSettings' => $reviewers->settings(),
'isAdmin' => $this->isAdmin(),
'quoteVersions' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? $this->requotes()->versions($onboarding) : collect(),
]);
@ -269,13 +274,12 @@ class CourierOnboardings extends Controller
public function finalApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureAdmin();
$data = $request->validate([
'courier_name' => [$onboarding->courier_name ? 'nullable' : 'required', 'string', 'max:100'],
'reconciler' => ['nullable', 'string', 'max:100'],
'note' => ['nullable', 'string', 'max:1000'],
], ['courier_name.required' => '请填写系统快递名称(需与报价名、发货数据里的快递名称完全一致)。']);
$this->service->finalApprove($onboarding, $data, (int) user_id());
$onboarding = $this->service->finalApprove($onboarding, $data, (int) user_id());
if (! $onboarding->quote_version_id) {
return $this->back($onboarding, '财务复审通过。快递没有填写报价明细,请在报价管理补录后再发起报价确认。');

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\SystemSettings;
use App\Support\CourierOnboardingReviewers;
use App\Utilities\SystemAccess;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class WorkflowReviewers extends Controller
{
public function __construct(private readonly CourierOnboardingReviewers $reviewers)
{
$this->middleware(function ($request, $next) {
abort_unless(SystemAccess::canOpenSettings(), 403);
return $next($request);
});
}
public function index()
{
return view('system_settings.workflow-reviewers.index', [
'reviewers' => $this->reviewers->settings(), 'history' => $this->reviewers->history(),
]);
}
public function search(Request $request)
{
$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');
} catch (\Throwable $e) {
return response()->json(['message' => '钉钉花名册暂时无法查询,请稍后重试。'], 503)->header('Cache-Control', 'no-store');
}
}
public function update(Request $request)
{
$data = $request->validate([
'first' => ['required', 'array', 'min:1', 'max:10'],
'first.*' => ['required', 'string', 'max:100', 'distinct'],
'final' => ['required', 'array', 'min:1', 'max:10'],
'final.*' => ['required', 'string', 'max:100', 'distinct'],
], ['first.required' => '请至少选择一名初审人员。', 'final.required' => '请至少选择一名复审人员。']);
$this->reviewers->save($data, user());
return redirect()->route('system-settings.workflow-reviewers.index')->with('success', '审核人员已保存,立即对待审核申请生效。');
}
}

View File

@ -94,6 +94,20 @@ class FinanceRoster
];
}
/** Minimal, bounded directory search for workflow selection; no HR dataset is cached. */
public function search(string $term): array
{
if (trim($term) === '') {
return [];
}
$like = '%' . str_replace(['!', '%', '_', '['], ['!!', '!%', '!_', '!['], trim($term)) . '%';
return $this->queries->forSource('operations_replica')->select(
"SELECT TOP (12) flowerNameId, name, flowerName FROM rpt.hr_flowerName_book
WHERE leaveDate IS NULL AND flowerNameId IS NOT NULL
AND (name LIKE ? ESCAPE '!' OR flowerName LIKE ? ESCAPE '!') ORDER BY Id DESC", [$like, $like]
);
}
private function isResigned(mixed $value): bool
{
$status = trim((string) $value);

View File

@ -0,0 +1,164 @@
<?php
namespace App\Support;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\User;
use App\Models\Business\CourierOnboarding;
use App\Services\Auth\FinanceRoster;
use App\Utilities\SidebarPermissions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CourierOnboardingReviewers
{
public const KEY = 'workflow.courier_onboarding_reviewers';
public const AUDIT_PREFIX = 'workflow.courier_onboarding_reviewer_audit.';
public function __construct(private readonly FinanceRoster $roster)
{
}
public function settings(): array
{
// Read current company settings directly: permission changes must not use model/settings caches.
$value = DB::table('settings')->where('company_id', company_id())->where('key', self::KEY)->value('value');
return (json_decode($value ?? '', true) ?: []) + ['first' => [], 'final' => []];
}
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, User $actor): void
{
$resolved = [];
$next = ['first' => [], 'final' => []];
foreach (array_keys($next) as $stage) {
foreach ($data[$stage] as $dingId) {
if (! isset($resolved[$dingId])) {
$account = $this->account($dingId);
if (! $account) {
throw ValidationException::withMessages([$stage => '所选人员须有本公司启用的钉钉绑定账号,并拥有快递入驻页面权限。']);
}
$roster = $this->eligible($dingId);
if (! $roster) {
throw ValidationException::withMessages([$stage => '所选人员已离职、停用或不在当前财务准入范围,请重新选择。']);
}
$resolved[$dingId] = ['dingtalk_user_id' => $dingId, 'display_name' => $roster['display_name']];
}
$next[$stage][] = $resolved[$dingId];
}
}
// At least two distinct identities must be able to complete the workflow.
if (count($resolved) < 2) {
throw ValidationException::withMessages(['final' => '初审和复审不能仅配置同一人,请至少选择两名不同人员。']);
}
DB::transaction(function () use ($next, $actor): void {
DB::table('companies')->where('id', company_id())->lockForUpdate()->first();
$previous = $this->settings();
$next['updated_at'] = now()->toIso8601String();
$next['updated_by'] = $actor->name;
DB::table('settings')->updateOrInsert(['company_id' => company_id(), 'key' => self::KEY], [
'value' => json_encode($next, JSON_UNESCAPED_UNICODE),
]);
DB::table('settings')->insert([
'company_id' => company_id(), 'key' => self::AUDIT_PREFIX . Str::ulid(),
'value' => json_encode(['at' => $next['updated_at'], 'actor_id' => $actor->id,
'actor' => $actor->name, 'before' => $previous, 'after' => $next], JSON_UNESCAPED_UNICODE),
]);
});
}
public function search(string $term): array
{
return array_map(function (array $row): array {
$dingId = (string) $row['flowerNameId'];
$account = $this->account($dingId);
$eligible = $account ? $this->eligible($dingId) : null;
return [
'id' => $dingId,
'name' => trim((string) ($row['flowerName'] ?: $row['name'])),
'account' => $account?->name,
'selectable' => $eligible !== null,
'reason' => ! $account ? '需先登录绑定钉钉、启用账号并开通快递入驻页面权限'
: ($eligible === null ? '不符合当前在职或财务准入条件' : '可选'),
];
}, $this->roster->search($term));
}
public function stage(CourierOnboarding $onboarding): string
{
return $onboarding->status === CourierOnboarding::STATUS_FIRST_APPROVED ? 'final' : 'first';
}
public function assertAllowed(CourierOnboarding $onboarding, int $userId, string $stage): void
{
abort_unless((int) $onboarding->company_id === (int) company_id(), 403);
$settings = $this->settings();
if ($settings['first'] === [] || $settings['final'] === []) {
throw ValidationException::withMessages(['approval' => '尚未配置初审和复审人员,请管理员到系统设置 → 流程审核权限设置。']);
}
$identity = DingTalkIdentity::query()->where('user_id', $userId)->first();
abort_unless($identity && in_array($identity->dingtalk_user_id, array_column($settings[$stage], 'dingtalk_user_id'), true)
&& $this->account($identity->dingtalk_user_id)?->id === $userId, 403, '你不是当前环节的指定审核人员。');
if ($stage === 'final' && (int) $onboarding->first_reviewed_by === $userId && ! $this->allowSameReviewer()) {
throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请另一名指定复审人员处理。']);
}
if (! $this->eligible($identity->dingtalk_user_id)) {
abort(403, '当前花名册身份已失效,不能审核。');
}
}
public function access(CourierOnboarding $onboarding, int $userId): array
{
$stage = $this->stage($onboarding);
try {
$this->assertAllowed($onboarding, $userId, $stage);
return ['allowed' => true, 'message' => ''];
} catch (ValidationException $e) {
return ['allowed' => false, 'message' => collect($e->errors())->flatten()->first()];
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
return ['allowed' => false, 'message' => $e->getMessage()];
}
}
private function account(string $dingId): ?User
{
$identities = DingTalkIdentity::query()->where('dingtalk_user_id', $dingId)->with('user')->limit(2)->get();
if ($identities->count() !== 1) {
return null;
}
$user = $identities->first()->user;
if (! $user || ! $user->enabled || ! $user->companies()->where('companies.id', company_id())->exists()) {
return null;
}
$allowed = SidebarPermissions::allowedKeys($user);
if ($allowed !== null && ! array_intersect(['courier-payables', 'courier-payables/courier-onboarding'], $allowed)) {
return null;
}
return $user;
}
private function eligible(string $dingId): ?array
{
try {
return $this->roster->findEligible($dingId);
} catch (\Throwable $e) {
// Do not expose connection details or use a stale HR grant.
throw ValidationException::withMessages(['approval' => '钉钉花名册暂时无法核验,请稍后重试。']);
}
}
private function allowSameReviewer(): bool
{
return app()->environment('local', 'testing') && config('courier_onboarding.allow_same_reviewer');
}
}

View File

@ -161,40 +161,46 @@ class CourierOnboardingService
public function firstApprove(CourierOnboarding $onboarding, int $userId, ?string $note): CourierOnboarding
{
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED], '只有待初审的申请可以初审通过。');
return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $note): CourierOnboarding {
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED], '只有待初审的申请可以初审通过。');
return $this->transition($onboarding, CourierOnboarding::STATUS_FIRST_APPROVED, 'first_approved', $note, $userId, [
'first_reviewed_by' => $userId,
'first_reviewed_at' => now(),
]);
return $this->transition($onboarding, CourierOnboarding::STATUS_FIRST_APPROVED, 'first_approved', $note, $userId, [
'first_reviewed_by' => $userId,
'first_reviewed_at' => now(),
]);
});
}
public function returnForChanges(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding
{
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED], '只有待审核的申请可以退回补件。');
return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $reason): CourierOnboarding {
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED], '只有待审核的申请可以退回补件。');
$expiresAt = $onboarding->expires_at;
if ($expiresAt === null || $expiresAt->lt(now()->addDays(7))) {
$expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay();
}
$expiresAt = $onboarding->expires_at;
if ($expiresAt === null || $expiresAt->lt(now()->addDays(7))) {
$expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay();
}
return $this->transition($onboarding, CourierOnboarding::STATUS_RETURNED, 'returned', $reason, $userId, [
'return_reason' => $reason,
'expires_at' => $expiresAt,
'first_reviewed_by' => null,
'first_reviewed_at' => null,
]);
return $this->transition($onboarding, CourierOnboarding::STATUS_RETURNED, 'returned', $reason, $userId, [
'return_reason' => $reason,
'expires_at' => $expiresAt,
'first_reviewed_by' => null,
'first_reviewed_at' => null,
]);
});
}
public function reject(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding
{
$this->assertStatus($onboarding, [
CourierOnboarding::STATUS_SUBMITTED,
CourierOnboarding::STATUS_FIRST_APPROVED,
CourierOnboarding::STATUS_RETURNED,
], '当前状态不能标记为不予合作。');
return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $reason): CourierOnboarding {
$this->assertStatus($onboarding, [
CourierOnboarding::STATUS_SUBMITTED,
CourierOnboarding::STATUS_FIRST_APPROVED,
CourierOnboarding::STATUS_RETURNED,
], '当前状态不能标记为不予合作。');
return $this->transition($onboarding, CourierOnboarding::STATUS_REJECTED, 'rejected', $reason, $userId);
return $this->transition($onboarding, CourierOnboarding::STATUS_REJECTED, 'rejected', $reason, $userId);
});
}
public function cancel(CourierOnboarding $onboarding, int $userId): CourierOnboarding
@ -244,16 +250,12 @@ class CourierOnboardingService
*/
public function finalApprove(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding
{
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。');
if ((int) $onboarding->first_reviewed_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) {
throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请换一位管理员复审。']);
}
return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($data, $userId): CourierOnboarding {
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。');
// 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。
$courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name);
$this->assertCourierNameAvailable($onboarding, $courierName);
return DB::transaction(function () use ($onboarding, $data, $courierName, $userId): CourierOnboarding {
// 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。
$courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name);
$this->assertCourierNameAvailable($onboarding, $courierName);
$onboarding->forceFill([
'courier_name' => $courierName,
'reconciler' => filled($data['reconciler'] ?? null) ? trim((string) $data['reconciler']) : $onboarding->reconciler,
@ -275,6 +277,20 @@ class CourierOnboardingService
});
}
private function review(CourierOnboarding $onboarding, int $userId, \Closure $action): CourierOnboarding
{
return DB::transaction(function () use ($onboarding, $userId, $action): CourierOnboarding {
// Serialize all review outcomes on the current row, including stale/repeated requests.
$current = CourierOnboarding::query()->disableCache()->lockForUpdate()->findOrFail($onboarding->id);
$this->assertStatus($current, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED,
CourierOnboarding::STATUS_RETURNED], '申请已由其他人员处理,请刷新页面查看最新状态。');
$reviewers = app(CourierOnboardingReviewers::class);
$reviewers->assertAllowed($current, $userId, $reviewers->stage($current));
return $action($current);
});
}
public function activate(CourierOnboarding $onboarding, int $userId, ?string $offlineNote): CourierOnboarding
{
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_APPROVED], '只有复审通过的申请可以开通。');

View File

@ -5,6 +5,6 @@ return [
// 生产填公网地址(如 https://caiwu.jiansuntuzhi.cn:8442),保证内网生成的二维码手机也能打开。
'public_url' => env('COURIER_ONBOARDING_PUBLIC_URL'),
// 是否允许初审人自己做财务复审。默认不允许(两人把关);测试/预览环境可打开方便一个人走完流程。
// 仅 local/testing 环境允许初审人自己复审;生产始终由不同人员完成。
'allow_same_reviewer' => (bool) env('COURIER_ONBOARDING_ALLOW_SAME_REVIEWER', false),
];

View File

@ -0,0 +1,13 @@
# 快递入驻审核人员
管理员在「系统设置 → 流程审核权限」按姓名或花名查询钉钉花名册,分别加入初审、复审名单并保存。每环节至少 1 人、最多 10 人。
- 各环节任意一名指定人员通过即可,不是所有人会签;复审通过后沿用生成首版报价、自动推送 e签宝的流程。
- 同一申请的初审人与复审人必须不同。两份名单可以有交集,但不能只配置同一人;正式环境不能通过测试开关绕过。
- 所选人员须已绑定钉钉、本公司账号已启用且具有快递入驻页面权限;保存和实际审核时均核验当前花名册在职及现有财务准入条件。选人不授予管理员角色,也不扩大钉钉登录组织范围。
- 退回补件、不予合作由当前环节的指定人员处理;退回后重新提交,从初审重新开始。报价重推和开通仍由管理员处理。
- 未配置两个环节时暂停审核,页面会提示先设置,不自动放行所有管理员。上线不会根据截图姓名自动分配生产权限。
- 配置即时影响尚未完成的审核,不改已完成审核的人员、时间、意见;页面展示当前名单,处理记录展示实际操作人。
- 花名册不可用时暂不能保存或审核,恢复后重试;不使用过期花名册授权。
配置与变更记录使用现有公司级 `settings`,不新增数据库迁移。不保存完整花名册,只保存指定人员的唯一身份和显示名。审核操作按申请行加锁后重新核对当前状态,重复或过期请求不会再次生成报价。

View File

@ -235,6 +235,15 @@
</div>
<aside class="cod__side">
<section class="cod__panel" aria-label="指定审核人员">
<div class="cod__panel-head"><h2 class="cod__panel-title">指定审核人员</h2></div>
<div class="cod__panel-body cod__stack">
<p class="cod__help" style="margin:0">初审:{{ implode('、', array_column($reviewerSettings['first'], 'display_name')) ?: '尚未配置' }}</p>
<p class="cod__help" style="margin:0">复审:{{ implode('、', array_column($reviewerSettings['final'], 'display_name')) ?: '尚未配置' }}</p>
<p class="cod__help" style="margin:0">各环节任意一人通过即可,初审与复审须由不同人员完成。</p>
@if ($isAdmin)<a class="cod__button" href="{{ route('system-settings.workflow-reviewers.index') }}">设置审核人员</a>@endif
</div>
</section>
@if (in_array($status, ['invited', 'returned'], true))
<section class="cod__panel" aria-labelledby="cod-qr">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-qr"><span class="material-icons-outlined" aria-hidden="true">qr_code_2</span>入驻二维码</h2><span class="cod__help">{{ $onboarding->expires_at ? '有效期至 ' . $onboarding->expires_at->format('Y-m-d') : '' }}</span></div>
@ -261,12 +270,16 @@
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-first"><span class="material-icons-outlined" aria-hidden="true">fact_check</span>初审</h2></div>
<div class="cod__panel-body cod__stack">
<p class="cod__help" style="margin:0">核对营业执照、开户证明和盖章报价单是否与填写一致。</p>
@if ($canFirstReview)
<form method="POST" action="{{ route('business.courier-onboardings.first-approve', $onboarding) }}" class="cod__stack">
@csrf
<div class="cod__field"><label for="cod-first-note">初审意见(选填)</label><textarea id="cod-first-note" name="note" maxlength="1000"></textarea></div>
<button type="submit" class="cod__button cod__button--primary"><span class="material-icons-outlined" aria-hidden="true">done</span>初审通过</button>
</form>
@include('business.pages.courier-payables.partials.courier-onboarding-return', ['onboarding' => $onboarding])
@else
<p class="cod__help" style="margin:0">{{ $reviewAccess['message'] }}</p>
@endif
</div>
</section>
@elseif ($status === 'first_approved')
@ -293,12 +306,10 @@
<div class="cod__field"><label for="cod-final-note">复审意见(选填)</label><textarea id="cod-final-note" name="note" maxlength="1000"></textarea></div>
<button type="submit" class="cod__button cod__button--primary"><span class="material-icons-outlined" aria-hidden="true">verified</span>复审通过,生成首版报价</button>
</form>
@elseif ($isAdmin)
<div class="cod__alert" style="margin:0"><span class="material-icons-outlined" aria-hidden="true">info</span><span>这条申请的初审是你做的。为防止一人包办,复审必须换另一位管理员账号登录来做,复审按钮会出现在这里。</span></div>
@else
<p class="cod__help" style="margin:0">等待管理员(财务)复审。</p>
@endif
@include('business.pages.courier-payables.partials.courier-onboarding-return', ['onboarding' => $onboarding])
@else
<p class="cod__help" style="margin:0">{{ $reviewAccess['message'] }}</p>
@endif
</div>
</section>
@elseif ($status === 'approved')

View File

@ -6,7 +6,7 @@
window.caiwuSettingsNavigation = true;
let pending;
const supports = url => url.origin === location.origin
&& /\/system-settings\/(?:(?:users|groups)(?:\/create|\/\d+\/edit)?|users\/\d+\/groups|permissions|sidebar)$/.test(url.pathname);
&& /\/system-settings\/(?:(?:users|groups)(?:\/create|\/\d+\/edit)?|users\/\d+\/groups|permissions|sidebar|workflow-reviewers)$/.test(url.pathname);
async function navigate(url, push = true) {
const current = document.querySelector('[data-settings-workspace]');
if (!current) return;

View File

@ -1,11 +1,13 @@
@include('system_settings.partials.styles')
@include('system_settings.partials.navigation-script')
@include('system_settings.partials.workflow-reviewers-script')
<nav class="mb-6 flex flex-wrap items-center gap-3 border-b border-gray-200 bg-white p-3" aria-label="系统设置">
@php
$items = [
['system-settings.users.index', '用户管理', 'people'],
['system-settings.dingtalk-bindings.index', '钉钉绑定', 'admin_panel_settings'],
['system-settings.permissions.index', '权限管理', 'admin_panel_settings'],
['system-settings.workflow-reviewers.index', '流程审核权限', 'fact_check'],
['system-settings.databases.index', '数据库管理', 'storage'],
];
@endphp

View File

@ -0,0 +1,83 @@
@once
@push('scripts_start')
<script>
(() => {
if (window.caiwuWorkflowReviewers) return;
window.caiwuWorkflowReviewers = true;
const people = new Map();
let pending;
async function search(form) {
const input = form.querySelector('[data-reviewer-search]');
const status = form.querySelector('[data-reviewer-search-status]');
const results = form.querySelector('[data-reviewer-results]');
if (!input.value.trim()) { status.textContent = '请输入姓名或花名后查询。'; return; }
pending?.abort();
const controller = new AbortController();
pending = controller;
status.textContent = '正在查询钉钉花名册…';
results.replaceChildren();
const url = new URL(form.dataset.searchUrl);
url.searchParams.set('q', input.value.trim());
try {
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();
for (const person of data.people) {
people.set(person.id, person);
const row = document.createElement('div');
row.className = 'flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 py-3 text-sm';
const name = document.createElement('span');
name.textContent = person.name + (person.account ? ' · 账号:' + person.account : '') + ' · ' + person.reason;
row.append(name);
const actions = document.createElement('div');
actions.className = 'flex gap-3';
for (const [stage, label] of [['first','加入初审'],['final','加入复审']]) {
const button = document.createElement('button');
button.type = 'button'; button.textContent = label;
button.dataset.reviewerAdd = stage; button.dataset.reviewerId = person.id;
button.disabled = !person.selectable;
button.className = person.selectable ? 'settings-accent' : 'text-gray-600 cursor-not-allowed';
actions.append(button);
}
row.append(actions); results.append(row);
}
status.textContent = data.people.length ? '最多显示 12 人,重名请核对对应系统账号;可缩小查询范围。' : '未找到人员,请检查姓名或花名后重试。';
} catch (error) {
if (error.name !== 'AbortError') status.textContent = '钉钉花名册暂时无法查询,请稍后重新点击查询人员。';
}
}
document.addEventListener('click', event => {
const button = event.target.closest('[data-reviewer-search-button], [data-reviewer-add], [data-reviewer-remove]');
const form = button?.closest('[data-reviewers-form]');
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);
if (!person?.selectable) return;
const stage = button.dataset.reviewerAdd;
const list = form.querySelector(`[data-reviewer-selected="${stage}"]`);
const selected = [...list.querySelectorAll('input')];
const status = form.querySelector('[data-reviewer-search-status]');
if (selected.some(input => input.value === person.id)) { status.textContent = '该人员已在此环节名单中。'; return; }
if (selected.length >= 10) { status.textContent = '每个环节最多选择 10 人。'; return; }
const row = document.createElement('li');
row.className = 'flex items-center justify-between gap-3 rounded-lg border border-gray-200 px-3 py-2 text-sm';
const input = document.createElement('input');
input.type = 'hidden'; input.name = stage + '[]'; input.value = person.id;
const name = document.createElement('span'); name.textContent = person.name;
const remove = document.createElement('button');
remove.type = 'button'; remove.dataset.reviewerRemove = ''; remove.className = 'settings-accent'; remove.textContent = '移除';
remove.setAttribute('aria-label', '移除' + person.name);
row.append(input, name, remove); list.append(row);
status.textContent = '已加入名单,请保存审核人员。';
});
document.addEventListener('keydown', event => {
if (event.key === 'Enter' && event.target.matches('[data-reviewer-search]')) {
event.preventDefault(); search(event.target.closest('[data-reviewers-form]'));
}
});
})();
</script>
@endpush
@endonce

View File

@ -7,6 +7,7 @@
<table class="min-w-full text-sm text-left">
<thead class="bg-gray-50"><tr><th class="px-5 py-3">设置项</th><th class="px-5 py-3">作用</th><th class="px-5 py-3 text-right">操作</th></tr></thead>
<tbody>
<tr><td class="px-5 py-4 whitespace-nowrap">流程审核权限</td><td class="px-5 py-4">从钉钉花名册指定快递入驻初审和复审人员,复审任意一人通过即可。</td><td class="px-5 py-4 text-right whitespace-nowrap"><a class="settings-accent" href="{{ route('system-settings.workflow-reviewers.index') }}">设置审核人员</a></td></tr>
<tr><td class="px-5 py-4 whitespace-nowrap">用户可访问页面</td><td class="px-5 py-4">设置用户或角色可以访问的业务页面。主管理员始终拥有全部页面权限。</td><td class="px-5 py-4 text-right whitespace-nowrap"><a class="settings-accent" href="{{ route('system-settings.sidebar.edit') }}">设置页面权限</a></td></tr>
@if (\App\Utilities\SystemAccess::isSuperAdmin())
<tr class="border-t border-gray-200"><td class="px-5 py-4 whitespace-nowrap">分组页面权限</td><td class="px-5 py-4">按岗位创建分组、添加成员,再统一设置业务页面。多个分组权限合并。</td><td class="px-5 py-4 text-right whitespace-nowrap"><a class="settings-accent" href="{{ route('system-settings.groups.index') }}">管理分组</a></td></tr>

View File

@ -0,0 +1,59 @@
<x-layouts.admin>
<x-slot name="title">流程审核权限</x-slot>
<x-slot name="content">
<div class="my-6" data-settings-workspace>
@include('system_settings.partials.navigation')
<section class="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 (session('success'))<p class="mb-4 text-sm" role="status">{{ session('success') }}</p>@endif
@if ($errors->any())<div class="mb-4 text-sm text-red-700" role="alert">@foreach ($errors->all() as $error)<p>{{ $error }}</p>@endforeach</div>@endif
@if (empty($reviewers['first']) || empty($reviewers['final']))<p class="mb-4 text-sm" role="status">尚未配置审核人员,待审核申请暂不能通过。请先完成下面的设置。</p>@endif
<form method="POST" action="{{ route('system-settings.workflow-reviewers.update') }}" data-reviewers-form data-search-url="{{ route('system-settings.workflow-reviewers.search') }}">
@csrf @method('PATCH')
<label for="reviewer-search" class="block text-xs font-semibold mb-2">查找钉钉花名册人员</label>
<div class="flex flex-wrap gap-2 mb-2">
<input id="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 (['first' => '初审人员', 'final' => '复审人员'] as $stage => $label)
<div>
<h3 class="text-sm font-semibold mb-2">{{ $label }} <span class="text-xs text-gray-600">· 任意一人通过</span></h3>
<ul data-reviewer-selected="{{ $stage }}" class="space-y-2" aria-label="{{ $label }}">
@foreach (old($stage, array_column($reviewers[$stage], 'dingtalk_user_id')) as $dingId)
@php $person = collect($reviewers[$stage])->firstWhere('dingtalk_user_id', $dingId); @endphp
<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">至少选择 1 人。{{ $stage === 'final' ? '任意一人通过后生成首版报价,其余人无需重复审核。' : '通过后转交复审人员处理。' }}</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>
</section>
<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)
<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']['first'], 'display_name')) }};复审:{{ implode('、', array_column($entry['after']['final'], 'display_name')) }}</p>
</div>
@empty
<p class="text-sm text-gray-600">尚无变更记录,首次保存后会记录操作人、时间及人员名单。</p>
@endforelse
</section>
</div>
</x-slot>
</x-layouts.admin>

View File

@ -556,6 +556,9 @@ Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], functi
Route::delete('dingtalk-bindings/identities/{identity}', 'SystemSettings\DingTalkBindings@destroy')->name('dingtalk-bindings.destroy');
Route::get('permissions', 'SystemSettings\Sidebar@index')->name('permissions.index');
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::get('sidebar', 'SystemSettings\Sidebar@edit')->name('sidebar.edit');
Route::patch('sidebar', 'SystemSettings\Sidebar@update')->name('sidebar.update');

View File

@ -3,6 +3,7 @@
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\Role;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierOnboarding;
@ -11,11 +12,15 @@ use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Common\Company;
use App\Support\CourierQuoteStructure;
use App\Support\CourierOnboardingReviewers;
use App\Support\CourierOnboardingService;
use App\Services\Auth\FinanceRoster;
use App\Support\QrCode;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class CourierOnboardingWorkflowTest extends FeatureTestCase
@ -26,6 +31,14 @@ class CourierOnboardingWorkflowTest extends FeatureTestCase
Storage::fake('uploads');
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->mock(FinanceRoster::class, function ($mock): void {
$mock->shouldReceive('findEligible')->andReturn(['display_name' => '测试审核人员'])->byDefault();
});
$this->bindReviewer($this->user);
$person = ['dingtalk_user_id' => 'reviewer-' . $this->user->id, 'display_name' => $this->user->name];
DB::table('settings')->updateOrInsert(['company_id' => $this->company->id, 'key' => CourierOnboardingReviewers::KEY], [
'value' => json_encode(['first' => [$person], 'final' => [$person]]),
]);
}
public function test_list_page_renders_and_invite_generates_qr_code(): void
@ -500,7 +513,7 @@ class CourierOnboardingWorkflowTest extends FeatureTestCase
$this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $other->fresh()->status);
}
public function test_non_admin_cannot_final_review_or_activate(): void
public function test_unassigned_user_cannot_final_review_and_activation_stays_admin_only(): void
{
$onboarding = $this->submittedOnboarding();
$onboarding->update(['status' => CourierOnboarding::STATUS_FIRST_APPROVED, 'first_reviewed_by' => 999]);
@ -1271,10 +1284,145 @@ class CourierOnboardingWorkflowTest extends FeatureTestCase
$reviewer->companies()->attach($this->company->id);
$reviewer->roles()->attach(Role::where('name', $role)->firstOrFail()->id);
$reviewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
if ($role === 'admin') {
$this->bindReviewer($reviewer);
$settings = app(CourierOnboardingReviewers::class)->settings();
$settings['final'][] = ['dingtalk_user_id' => 'reviewer-' . $reviewer->id, 'display_name' => $reviewer->name];
DB::table('settings')->updateOrInsert(['company_id' => $this->company->id, 'key' => CourierOnboardingReviewers::KEY],
['value' => json_encode($settings)]);
}
return $reviewer;
}
private function bindReviewer($user): void
{
DingTalkIdentity::create(['user_id' => $user->id, 'union_id' => 'union-reviewer-' . $user->id,
'dingtalk_user_id' => 'reviewer-' . $user->id, 'bound_at' => now()]);
}
public function test_any_designated_final_reviewer_can_pass_and_stale_requests_do_not_duplicate_quotes(): void
{
$onboarding = $this->submittedOnboarding();
$one = $this->createReviewer();
$two = $this->createReviewer();
// A configured reviewer needs no system administration role.
$two->roles()->sync([Role::where('name', 'user')->firstOrFail()->id]);
$service = app(CourierOnboardingService::class);
$service->firstApprove($onboarding, $this->user->id, null);
$stale = $onboarding->fresh();
$this->loginAs($two, $this->company)->get(route('business.courier-onboardings.show', $onboarding))
->assertSeeText('复审通过,生成首版报价');
$this->post(route('business.courier-onboardings.final-approve', $onboarding), [])->assertSessionHasNoErrors();
$this->assertSame($two->id, $onboarding->fresh()->final_reviewed_by);
$versions = CourierQuoteVersion::count();
try {
$service->finalApprove($stale, [], $one->id);
$this->fail('过期复审请求不应再次成功');
} catch (\Illuminate\Validation\ValidationException $e) {
$this->assertArrayHasKey('status', $e->errors());
}
$this->assertSame($versions, CourierQuoteVersion::count());
$this->assertSame(1, $onboarding->events()->where('event_type', 'final_approved')->count());
$this->withExceptionHandling()->loginAs($one, $this->company)
->post(route('business.courier-onboardings.return', $onboarding), ['reason' => '过期退回'])
->assertSessionHasErrors('status');
$this->assertSame(CourierOnboarding::STATUS_APPROVED, $onboarding->fresh()->status);
}
public function test_unconfigured_and_unassigned_admin_cannot_review(): void
{
$onboarding = $this->submittedOnboarding();
DB::table('settings')->where('key', CourierOnboardingReviewers::KEY)->delete();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-onboardings.first-approve', $onboarding))
->assertSessionHasErrors('approval');
$other = $this->createReviewer();
$settings = app(CourierOnboardingReviewers::class)->settings();
$settings['first'] = $settings['final'];
DB::table('settings')->updateOrInsert(['company_id' => $this->company->id, 'key' => CourierOnboardingReviewers::KEY], ['value' => json_encode($settings)]);
foreach (['first-approve' => [], 'return' => ['reason' => '退回'], 'reject' => ['reason' => '拒绝']] as $action => $data) {
$this->post(route('business.courier-onboardings.' . $action, $onboarding), $data)->assertForbidden();
}
$this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->fresh()->status);
}
public function test_final_review_actions_require_current_stage_reviewer_and_separate_people(): void
{
$onboarding = $this->submittedOnboarding();
$other = $this->createReviewer();
$this->loginAs()->post(route('business.courier-onboardings.first-approve', $onboarding));
foreach (['final-approve' => [], 'return' => ['reason' => '退回'], 'reject' => ['reason' => '拒绝']] as $action => $data) {
$this->withExceptionHandling()->post(route('business.courier-onboardings.' . $action, $onboarding), $data)
->assertSessionHasErrors('approval');
}
$this->loginAs($other, $this->company)->post(route('business.courier-onboardings.return', $onboarding), ['reason' => '资料不清晰'])
->assertSessionHasNoErrors();
$this->assertSame(CourierOnboarding::STATUS_RETURNED, $onboarding->fresh()->status);
$this->assertNull($onboarding->fresh()->first_reviewed_by);
}
public function test_invalid_roster_and_upstream_failure_block_review_without_writes(): void
{
$onboarding = $this->submittedOnboarding();
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andReturnNull();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-onboardings.first-approve', $onboarding))->assertForbidden();
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andThrow(new \RuntimeException('private connection detail'));
$this->post(route('business.courier-onboardings.first-approve', $onboarding))->assertSessionHasErrors('approval');
$this->assertStringNotContainsString('private connection', json_encode(session('errors')->all()));
$this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->fresh()->status);
$this->assertSame(0, $onboarding->events()->where('event_type', 'first_approved')->count());
}
public function test_disabled_unbound_and_other_company_users_cannot_review(): void
{
$onboarding = $this->submittedOnboarding();
$this->user->update(['enabled' => false]);
$this->assertReviewForbidden($onboarding);
$this->user->update(['enabled' => true]);
$this->user->companies()->detach($this->company->id);
$this->assertReviewForbidden($onboarding);
$this->user->companies()->attach($this->company->id);
DingTalkIdentity::where('user_id', $this->user->id)->delete();
$this->assertReviewForbidden($onboarding);
$this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->fresh()->status);
}
private function assertReviewForbidden(CourierOnboarding $onboarding): void
{
try {
app(CourierOnboardingService::class)->firstApprove($onboarding, $this->user->id, null);
$this->fail('无效账号不能审核');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
$this->assertSame(403, $e->getStatusCode());
}
}
public function test_production_cannot_bypass_separation_and_configuration_changes_apply_immediately(): void
{
$onboarding = $this->submittedOnboarding();
$service = app(CourierOnboardingService::class);
$service->firstApprove($onboarding, $this->user->id, null);
config(['courier_onboarding.allow_same_reviewer' => true]);
app()->detectEnvironment(fn () => 'production');
try {
$service->finalApprove($onboarding, [], $this->user->id);
$this->fail('生产环境不能允许一人包办审核');
} catch (\Illuminate\Validation\ValidationException $e) {
$this->assertArrayHasKey('approval', $e->errors());
}
app()->detectEnvironment(fn () => 'testing');
config(['courier_onboarding.allow_same_reviewer' => false]);
$one = $this->createReviewer();
$two = $this->createReviewer();
app(CourierOnboardingReviewers::class)->save([
'first' => ['reviewer-' . $this->user->id], 'final' => ['reviewer-' . $two->id],
], $this->user);
$this->withExceptionHandling()->loginAs($one, $this->company)
->post(route('business.courier-onboardings.final-approve', $onboarding))->assertForbidden();
$this->loginAs($two, $this->company)->post(route('business.courier-onboardings.final-approve', $onboarding))->assertSessionHasNoErrors();
$this->assertSame($two->id, $onboarding->fresh()->final_reviewed_by);
}
private function fakeEsign(): void
{
config([

View File

@ -0,0 +1,153 @@
<?php
namespace Tests\Feature\SystemSettings;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\User;
use App\Models\Common\Company;
use App\Services\Auth\FinanceRoster;
use App\Support\CourierOnboardingReviewers;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class WorkflowReviewersTest extends FeatureTestCase
{
private mixed $rosterOutcome = ['display_name' => '测试同名人员'];
private bool $searchFails = false;
protected function setUp(): void
{
parent::setUp();
$this->mock(FinanceRoster::class, function ($mock): void {
$mock->shouldReceive('findEligible')->andReturnUsing(function () {
if ($this->rosterOutcome instanceof \Throwable) throw $this->rosterOutcome;
return $this->rosterOutcome;
});
$mock->shouldReceive('search')->andReturnUsing(function () {
if ($this->searchFails) throw new \RuntimeException('secret host');
return [
['flowerNameId' => 'ding-one', 'name' => '测试人员', 'flowerName' => '测试花名'],
['flowerNameId' => 'ding-unbound', 'name' => '未绑定人员', 'flowerName' => ''],
];
});
});
}
private function person(string $id, string $name = '测试同名人员'): User
{
$user = User::create(['name' => $name, 'email' => $id . '@example.test', 'password' => 'password',
'locale' => 'zh-CN', 'enabled' => true, 'landing_page' => 'dashboard', 'created_from' => 'core::test']);
$user->companies()->attach($this->company->id);
$user->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
$user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
DingTalkIdentity::create(['user_id' => $user->id, 'union_id' => 'union-' . $id, 'dingtalk_user_id' => $id,
'bound_at' => now()]);
return $user;
}
private function eligibleRoster(): void
{
$this->rosterOutcome = ['display_name' => '测试同名人员'];
}
public function test_admin_saves_distinct_identities_and_audits_changes_without_granting_admin_roles(): void
{
$one = $this->person('ding-one');
$two = $this->person('ding-two');
$this->eligibleRoster();
$this->loginAs()->get(route('system-settings.workflow-reviewers.index'))->assertOk()->assertSeeText('尚未配置审核人员');
$this->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one'], 'final' => ['ding-one', 'ding-two']])
->assertRedirect(route('system-settings.workflow-reviewers.index'));
$settings = app(CourierOnboardingReviewers::class)->settings();
$this->assertSame(['ding-one', 'ding-two'], array_column($settings['final'], 'dingtalk_user_id'));
$this->assertSame('user', $two->roles()->first()->name);
$this->assertSame(1, count(app(CourierOnboardingReviewers::class)->history()));
// A real next request reloads all settings, including the audit namespace.
$this->company->makeCurrent(true);
$this->get(route('system-settings.workflow-reviewers.index'))->assertOk()->assertSeeText('配置变更记录');
$this->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-two'], 'final' => ['ding-one']]);
$history = app(CourierOnboardingReviewers::class)->history();
$this->assertCount(2, $history);
$this->assertSame('ding-one', $history[0]['before']['first'][0]['dingtalk_user_id']);
$this->assertSame($this->user->id, $history[0]['actor_id']);
}
public function test_regular_user_cannot_read_search_or_save_configuration(): void
{
$person = $this->person('ding-one');
$this->withExceptionHandling()->loginAs($person, $this->company)
->get(route('system-settings.workflow-reviewers.index'))->assertForbidden();
$this->getJson(route('system-settings.workflow-reviewers.search', ['q' => '测试']))->assertForbidden();
$this->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one'], 'final' => ['ding-one']])->assertForbidden();
}
public function test_directory_results_distinguish_bound_and_unbound_people_and_fail_safely(): void
{
$this->person('ding-one');
$this->loginAs()->getJson(route('system-settings.workflow-reviewers.search', ['q' => '测试']))
->assertOk()->assertJsonPath('people.0.selectable', true)->assertJsonPath('people.1.selectable', false)
->assertHeader('Cache-Control', 'no-store, private');
$this->searchFails = true;
$this->getJson(route('system-settings.workflow-reviewers.search', ['q' => '测试']))
->assertStatus(503)->assertDontSee('secret host');
}
public function test_save_rejects_missing_duplicate_same_person_inactive_and_unbound_candidates(): void
{
$one = $this->person('ding-one');
$two = $this->person('ding-two');
$this->eligibleRoster();
$this->withExceptionHandling()->loginAs()->patch(route('system-settings.workflow-reviewers.update'), ['first' => [], 'final' => []])
->assertSessionHasErrors(['first', 'final']);
$this->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one'], 'final' => ['ding-one']])
->assertSessionHasErrors('final');
$this->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one', 'ding-one'], 'final' => ['ding-two']])
->assertSessionHasErrors('first.0');
$two->update(['enabled' => false]);
$this->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one'], 'final' => ['ding-two']])
->assertSessionHasErrors('final');
$this->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one'], 'final' => ['not-bound']])
->assertSessionHasErrors('final');
$this->assertSame([], app(CourierOnboardingReviewers::class)->settings()['first']);
$this->assertSame([], app(CourierOnboardingReviewers::class)->history());
}
public function test_save_rejects_departed_or_failed_roster_and_revoked_page_access(): void
{
$this->person('ding-one');
$this->person('ding-two');
$this->rosterOutcome = null;
$this->withExceptionHandling()->loginAs()->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one'], 'final' => ['ding-two']])
->assertSessionHasErrors('first');
$this->rosterOutcome = new \RuntimeException('private connection');
$this->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one'], 'final' => ['ding-two']])
->assertSessionHasErrors('approval');
$this->eligibleRoster();
setting()->set('system.sidebar_permissions', json_encode(['configured' => ['role_user'], 'subjects' => ['role_user' => []]]));
setting()->save();
$this->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one'], 'final' => ['ding-two']])
->assertSessionHasErrors('first');
$this->assertSame([], app(CourierOnboardingReviewers::class)->history());
}
public function test_company_configuration_and_history_are_isolated(): void
{
$one = $this->person('ding-one');
$this->person('ding-two');
$this->eligibleRoster();
$this->loginAs()->patch(route('system-settings.workflow-reviewers.update'), ['first' => ['ding-one'], 'final' => ['ding-two']]);
$other = Company::create(['enabled' => true]);
$this->user->companies()->attach($other->id);
$this->loginAs($this->user, $other);
$this->assertSame([], app(CourierOnboardingReviewers::class)->settings()['first']);
$this->assertSame([], app(CourierOnboardingReviewers::class)->history());
try {
app(CourierOnboardingReviewers::class)->save(['first' => ['ding-one'], 'final' => ['ding-two']], $this->user);
$this->fail('其他公司人员不能被选为本公司审核人员');
} catch (\Illuminate\Validation\ValidationException $e) {
$this->assertArrayHasKey('first', $e->errors());
}
}
}

View File

@ -11,6 +11,20 @@ use Tests\TestCase;
class FinanceRosterTest extends TestCase
{
public function test_directory_search_is_bounded_parameterized_and_escapes_wildcards(): void
{
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
$query->shouldReceive('select')->once()->with(Mockery::on(function ($sql): bool {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
return str_contains($sql, 'TOP (12)') && str_contains($sql, 'flowerNameId') && ! str_contains($sql, "张'_%");
}), ["%张'!_!%![%", "%张'!_!%![%"])->andReturn([]);
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
$factory->shouldReceive('forSource')->once()->with('operations_replica')->andReturn($query);
$roster = new FinanceRoster($factory);
$this->assertSame([], $roster->search(''));
$this->assertSame([], $roster->search("张'_%["));
}
#[DataProvider('rosterCases')]
public function test_roster_authorization(array $overrides, bool $expected): void
{