CaiWuXiTong/app/Support/CourierOnboardingReviewers.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');
}
}