165 lines
7.3 KiB
PHP
165 lines
7.3 KiB
PHP
<?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');
|
|
}
|
|
}
|