where('type', $flowType) ->where('is_active', 1) ->first(); if (!$flow) { throw new BusinessException("审批流程未找到或已停用: {$flowType}"); } $steps = json_decode($flow->steps, true); if (!is_array($steps) || empty($steps)) { throw new BusinessException("流程步骤配置错误"); } // 2) 寻找首个有效步骤索引(考虑条件) $firstIndex = $this->getNextStepIndex($steps, $formData, -1); if ($firstIndex === null) { throw new BusinessException("未找到可执行的审批步骤"); } $firstStep = $steps[$firstIndex]; // 3) 计算实例截止时间(实例级,可扩展到步骤级) $deadlineAt = null; if (($flow->timeout_hours ?? 0) > 0) { $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); } // 4) 创建审批实例 $now = date('Y-m-d H:i:s'); $instanceId = Db::table('approval_instances')->insertGetId([ 'flow_type' => $flowType, 'biz_type' => $bizType, 'biz_id' => $bizId, 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'initiator_id' => $initiatorId, 'status' => 'processing', 'current_step' => $firstIndex, 'deadline_at' => $deadlineAt, 'created_at' => $now, 'updated_at' => $now, ]); // 5) 创建首步待办 $instance = (object)[ 'id' => $instanceId, 'flow_type' => $flowType, 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'initiator_id' => $initiatorId, 'current_step' => $firstIndex, 'status' => 'processing', ]; $approverIds = $this->resolveApprovers($firstStep, $instance, true); foreach ($approverIds as $uid) { // 唯一键会防重复 $this->insertApprovalRecord($instanceId, $firstIndex, $uid); } // 6) 通知审批人 & 抄送 $this->sendNotification($approverIds, $instance, 'pending', false); $this->notifyCCs($firstStep, $instance, 'pending'); return ['instance_id' => $instanceId]; }); } /** * 执行审批 */ public function approve(int $instanceId, int $approverId, string $action, string $comment = '') { if (!in_array($action, ['approve', 'reject'], true)) { throw new BusinessException("无效的审批操作"); } return Db::transaction(function () use ($instanceId, $approverId, $action, $comment) { // 锁实例,保证并发安全 $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); if (!$instance) { throw new BusinessException("审批实例未找到"); } if ($instance->status !== 'processing') { throw new BusinessException("流程已结束或不可审批"); } // 读取流程与当前步 $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); if (!$flow) throw new BusinessException("审批流程定义未找到"); $steps = json_decode($flow->steps, true); $idx = (int)$instance->current_step; $step = $steps[$idx] ?? null; if (!$step) throw new BusinessException("当前步骤配置错误"); // 锁当前步所有记录,避免同时推进 $pendingRecord = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('approver_id', $approverId) ->where('step_index', $idx) ->where('status', 'pending') ->lockForUpdate() ->first(); if (!$pendingRecord) { throw new BusinessException("未找到待审批记录"); } $now = date('Y-m-d H:i:s'); // 1) 更新当前人的审批记录 Db::table('approval_records')->where('id', $pendingRecord->id)->update([ 'action' => $action, 'status' => ($action === 'approve') ? 'approved' : 'rejected', 'comment' => $comment, 'approved_at' => $now, 'updated_at' => $now, ]); // 2) 聚合判断本步骤结果 $outcome = $this->getStepOutcome($instanceId, $idx); // 'pending'|'approve'|'reject' $type = $step['type'] ?? 'all'; $stepDone = false; $finalReject = false; $finalApprove = false; if ($type === 'any') { if ($outcome === 'reject') { $stepDone = true; $finalReject = true; } elseif ($outcome === 'approve') { $stepDone = true; $finalApprove = true; } } else { // all if ($outcome === 'reject') { $stepDone = true; $finalReject = true; } elseif ($outcome === 'approve') { $stepDone = true; $finalApprove = true; } } if (!$stepDone) { return ['status' => 'step_pending']; } // 3) 本步已有结论:关闭其余 pending 待办为 skipped $this->closeOtherPending($instanceId, $idx, [$approverId]); // 4) 步骤结论为拒绝 -> 结束 if ($finalReject) { Db::table('approval_instances')->where('id', $instanceId)->update([ 'status' => 'rejected', 'finished_at' => $now, 'updated_at' => $now, ]); $this->sendNotification([$instance->initiator_id], $instance, 'reject', false); return ['status' => 'completed', 'result' => 'reject']; } // 5) 进入下一步(基于表单快照与条件继续寻找) $formData = json_decode($instance->form_data, true) ?: []; $nextIndex = $this->getNextStepIndex($steps, $formData, $idx); if ($nextIndex === null) { // 流程结束(全通过) Db::table('approval_instances')->where('id', $instanceId)->update([ 'status' => 'approved', 'finished_at' => $now, 'updated_at' => $now, ]); $this->sendNotification([$instance->initiator_id], $instance, 'approve', false); return ['status' => 'completed', 'result' => 'approve']; } // 6) 推进实例 current_step(乐观条件) $affected = Db::table('approval_instances') ->where('id', $instanceId) ->where('current_step', $idx) ->where('status', 'processing') ->update([ 'current_step' => $nextIndex, 'updated_at' => $now, ]); if (!$affected) { throw new BusinessException('流程已被他人推进,请刷新后重试'); } // 7) 生成下一步待办 + 通知 $nextStep = $steps[$nextIndex]; $nextApprovers = $this->resolveApprovers($nextStep, $instance, true); foreach ($nextApprovers as $uid) { $this->insertApprovalRecord($instanceId, $nextIndex, $uid); } $this->sendNotification($nextApprovers, $instance, 'pending', false); $this->notifyCCs($nextStep, $instance, 'pending'); return ['status' => 'next_step', 'step_name' => $nextStep['name'] ?? ('Step '.$nextIndex)]; }); } /** * 撤回流程(可选) * 仅限发起人在第一条“已处理记录”出现前撤回 */ public function revoke(int $instanceId, int $initiatorId, string $reason = '') { return Db::transaction(function () use ($instanceId, $initiatorId, $reason) { $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); if (!$instance) throw new BusinessException('审批实例未找到'); if ((int)$instance->initiator_id !== (int)$initiatorId) throw new BusinessException('无权撤回'); if ($instance->status !== 'processing') throw new BusinessException('流程已结束或不可撤回'); // 已有处理记录则不允许撤回 $handled = Db::table('approval_records') ->where('instance_id', $instanceId) ->whereIn('status', ['approved','rejected','timeout','skipped']) ->count() > 0; if ($handled) throw new BusinessException('当前进度不可撤回'); $now = date('Y-m-d H:i:s'); Db::table('approval_instances')->where('id', $instanceId)->update([ 'status' => 'revoked', 'finished_at' => $now, 'updated_at' => $now, ]); // 关闭所有 pending 待办 Db::table('approval_records') ->where('instance_id', $instanceId) ->where('status', 'pending') ->update(['status' => 'skipped', 'approved_at' => $now, 'updated_at' => $now]); $this->sendNotification([$initiatorId], $instance, 'revoked', false); return ['status' => 'completed', 'result' => 'revoked']; }); } /** * 我的待办 */ public function getPendingApprovals(int $userId): array { return Db::table('approval_records as ar') ->join('approval_instances as ai', 'ar.instance_id', '=', 'ai.id') ->where('ar.approver_id', $userId) ->where('ar.status', 'pending') ->where('ai.status', 'processing') ->select('ai.*') ->distinct() ->get() ->toArray(); } /** * 审批历史 */ public function getApprovalHistory(int $instanceId): array { return Db::table('approval_records') ->where('instance_id', $instanceId) ->orderBy('step_index') ->orderByRaw('COALESCE(approved_at, created_at) asc') ->get() ->toArray(); } /** * 流程图数据(识别 skipped) */ public function getFlowDiagramData(int $instanceId): array { $instance = Db::table('approval_instances')->find($instanceId); if (!$instance) return []; $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); if (!$flow) return []; $steps = json_decode($flow->steps, true) ?: []; $records = Db::table('approval_records') ->where('instance_id', $instanceId) ->orderBy('step_index')->orderByRaw('COALESCE(approved_at, created_at) asc') ->get() ->groupBy('step_index'); $formData = json_decode($instance->form_data, true) ?: []; $data = []; foreach ($steps as $i => $step) { $actions = ($records->get($i) ?? collect())->toArray(); $status = 'pending'; if (!empty($actions)) { $hasReject = false; $allApproved = true; foreach ($actions as $a) { if ($a->status === 'rejected') $hasReject = true; if ($a->status !== 'approved') $allApproved = false; } if ($hasReject) $status = 'rejected'; elseif ($allApproved) $status = 'approved'; else $status = 'processing'; } else { // 无记录:可能被跳过、未到达、已过 if ($i < (int)$instance->current_step) { // 如果条件不满足,认为 skipped,否则视为 approved(历史兼容) if (isset($step['condition']) && !$this->evaluateCondition($step['condition'], $formData)) { $status = 'skipped'; } else { $status = 'approved'; } } elseif ($i === (int)$instance->current_step && $instance->status === 'processing') { $status = 'processing'; } else { // 还未到达 $status = 'pending'; } } $data[] = [ 'index' => $i, 'name' => $step['name'] ?? ('Step '.$i), 'type' => $step['type'] ?? 'all', 'status' => $status, 'actions' => $actions, ]; } return $data; } /** * 定时任务:处理超时实例(实例级) */ public function handleTimeouts() { $now = date('Y-m-d H:i:s'); $expired = Db::table('approval_instances') ->where('status', 'processing') ->whereNotNull('deadline_at') ->where('deadline_at', '<=', $now) ->get(); foreach ($expired as $instance) { Db::transaction(function () use ($instance, $now) { $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); if (!$flow) return; $final = ($flow->timeout_action === 'auto_approve') ? 'approved' : 'rejected'; $finalAction = ($flow->timeout_action === 'auto_approve') ? 'approve' : 'reject'; // 更新实例 Db::table('approval_instances')->where('id', $instance->id)->update([ 'status' => ($final === 'approved') ? 'approved' : 'rejected', 'finished_at' => $now, 'updated_at' => $now, ]); // 关闭当前步所有 pending 记录为 timeout Db::table('approval_records') ->where('instance_id', $instance->id) ->where('step_index', $instance->current_step) ->where('status', 'pending') ->update(['status' => 'timeout', 'approved_at' => $now, 'updated_at' => $now]); $this->notifyInitiator($instance, $finalAction); }); } } // === 内部工具 === /** * 解析审批人 * $avoidSelf:是否过滤发起人本人 */ private function resolveApprovers(array $step, object $instance, bool $avoidSelf = true): array { $resolved = []; $formData = json_decode($instance->form_data ?? '{}', true) ?: []; $confs = $step['approvers'] ?? []; foreach ($confs as $cfg) { $typ = $cfg['type'] ?? ''; $val = $cfg['value'] ?? null; $ids = []; switch ($typ) { case 'role': // $ids = $this->getUserIdsByRole($val); // 支持: // - {"type":"role","value":1} // 按 role_id // - {"type":"role","value":"销售经理"} // 按 roles.name // - {"type":"role","value":"销售经理","system_id": 2} // 限定系统;默认也包含全局(system_id=0) $val = $cfg['value'] ?? null; $sys = isset($cfg['system_id']) ? (int)$cfg['system_id'] : null; // include_global 默认 true(把 system_id=0 的“全局角色”也算上) $includeGlobal = array_key_exists('include_global', $cfg) ? (bool)$cfg['include_global'] : true; $ids = $this->getUserIdsByRole($val, $sys, true, $includeGlobal); break; case 'user': $ids = [(int)$val]; break; case 'initiator_leader': $leaderId = $this->getLeaderIdByUserId((int)$instance->initiator_id); if ($leaderId) $ids = [(int)$leaderId]; break; case 'field_user': if (isset($formData[$val])) $ids = [(int)$formData[$val]]; break; case 'dept_head': $head = $this->getDeptHeadId((int)$val); if ($head) $ids = [(int)$head]; break; default: throw new BusinessException("不支持的审批人类型: {$typ}"); } $resolved = array_merge($resolved, $ids); } $resolved = array_unique(array_filter(array_map('intval', $resolved))); if ($avoidSelf) { $resolved = array_values(array_filter($resolved, fn($id) => $id !== (int)$instance->initiator_id)); } if (empty($resolved)) { throw new BusinessException("步骤未解析到任何审批人"); } return $resolved; } /** * 通知抄送人 */ private function notifyCCs(array $step, object $instance, string $action = 'pending'): void { $ccConfig = $step['cc'] ?? []; if (empty($ccConfig)) return; $formData = json_decode($instance->form_data ?? '{}', true) ?: []; $uids = []; foreach ($ccConfig as $cc) { $typ = $cc['type'] ?? ''; $val = $cc['value'] ?? null; switch ($typ) { case 'role': $uids = array_merge($uids, $this->getUserIdsByRole($val)); break; case 'user': $uids[] = (int)$val; break; case 'initiator_leader': $leaderId = $this->getLeaderIdByUserId((int)$instance->initiator_id); if ($leaderId) $uids[] = (int)$leaderId; break; case 'field_user': if (isset($formData[$val])) $uids[] = (int)$formData[$val]; break; case 'dept_head': $head = $this->getDeptHeadId((int)$val); if ($head) $uids[] = (int)$head; break; default: // ignore break; } } $uids = array_unique(array_filter(array_map('intval', $uids))); if (empty($uids)) return; $now = date('Y-m-d H:i:s'); foreach ($uids as $uid) { // 唯一键防重;这里仍做一次 exists 判断 $exists = Db::table('approval_ccs') ->where('instance_id', $instance->id) ->where('user_id', $uid) ->count() > 0; if (!$exists) { Db::table('approval_ccs')->insert([ 'instance_id' => $instance->id, 'user_id' => $uid, 'notified_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); } } $this->sendNotification($uids, $instance, $action, true); } /** * 条件求值:支持 {field} {op} {value} * op: ==, !=, >, <, >=, <= */ private function evaluateCondition(?string $condition, array $formData): bool { if (!$condition) return true; $pattern = '/^(\w+)\s*(==|!=|>|<|>=|<=)\s*(.+)$/'; if (!preg_match($pattern, $condition, $m)) { throw new BusinessException("条件格式错误: {$condition}"); } $field = $m[1]; $op = $m[2]; $value = trim($m[3], "\"'"); $actual = $formData[$field] ?? null; // 数值比较 if (is_numeric($value) && is_numeric($actual)) { $value = (float)$value; $actual = (float)$actual; } switch ($op) { case '==': return $actual == $value; case '!=': return $actual != $value; case '>': return $actual > $value; case '<': return $actual < $value; case '>=': return $actual >= $value; case '<=': return $actual <= $value; } throw new BusinessException("不支持的操作符: {$op}"); } /** * 获取下一个有效步骤索引(考虑 condition) * @param int $currentStepIndex 从该索引之后开始找;如果传 -1 则从开头找“首个有效” */ private function getNextStepIndex(array $steps, array $formData, int $currentStepIndex): ?int { for ($i = $currentStepIndex + 1; $i < count($steps); $i++) { $s = $steps[$i]; if (!isset($s['condition']) || $this->evaluateCondition($s['condition'], $formData)) { return $i; } } return null; } /** * 聚合本步骤结论 * 返回:'pending' | 'approve' | 'reject' */ private function getStepOutcome(int $instanceId, int $stepIndex): string { $records = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('step_index', $stepIndex) ->get(); $hasPending = false; $hasReject = false; $allApproved = true; foreach ($records as $r) { if ($r->status === 'pending') { $hasPending = true; $allApproved = false; } elseif ($r->status === 'rejected') { $hasReject = true; $allApproved = false; } elseif ($r->status !== 'approved') { $allApproved = false; } } if ($hasPending) return 'pending'; if ($hasReject) return 'reject'; return $allApproved ? 'approve' : 'reject'; } /** * 关闭本步其余待办(标记为 skipped) */ private function closeOtherPending(int $instanceId, int $stepIndex, array $keepApproverIds = []): void { $now = date('Y-m-d H:i:s'); $q = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('step_index', $stepIndex) ->where('status', 'pending'); if (!empty($keepApproverIds)) { $q->whereNotIn('approver_id', $keepApproverIds); } $q->update([ 'status' => 'skipped', 'approved_at' => $now, 'updated_at' => $now, ]); } private function insertApprovalRecord(int $instanceId, int $stepIndex, int $approverId): void { $now = date('Y-m-d H:i:s'); // 防重复:唯一键兜底,这里先查一次 $exists = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('step_index', $stepIndex) ->where('approver_id', $approverId) ->count() > 0; if (!$exists) { Db::table('approval_records')->insert([ 'instance_id' => $instanceId, 'approver_id' => $approverId, 'step_index' => $stepIndex, 'action' => null, // 待办阶段无动作 'status' => 'pending', // 明确 pending 'comment' => null, 'approved_at' => null, // 未审批为 NULL 'created_at' => $now, 'updated_at' => $now, ]); } } private function sendNotification(array $userIds, object $instance, string $action, bool $isCC): void { $map = [ 'pending' => '待审批', 'approve' => '已通过', 'reject' => '已拒绝', 'revoked' => '已撤回', ]; $prefix = $isCC ? '[抄送]' : ''; $text = $map[$action] ?? $action; $msg = "{$prefix}您有一条审批 {$text}:流程ID {$instance->id},类型 {$instance->flow_type}"; // TODO: 接入你的消息系统(站内信/企微/飞书…) // MessageService::send($userIds, $msg); // echo "通知 ".implode(',', $userIds).":{$msg}\n"; } private function notifyInitiator(object $instance, string $result): void { $this->sendNotification([(int)$instance->initiator_id], $instance, $result, false); } // === 数据访问 === // private function getUserIdsByRole(string $roleCode): array // { // // 兼容 pluck 行为(不同版本可能返回 array 或 Collection) // $rows = Db::table('user_roles as ur') // ->join('roles as r', 'ur.role_id', '=', 'r.id') // ->where('r.code', $roleCode) // ->select('ur.user_id') // ->get(); // $ids = []; // foreach ($rows as $row) { // $ids[] = (int)$row->user_id; // } // return array_values(array_unique($ids)); // } /** * 从角色取用户ID列表 * * @param mixed $roleIdOrName 数字 => role_id,字符串 => roles.name 精确匹配 * @param int|null $systemId 可选:限定 roles.system_id;默认不传=不过滤 * @param bool $onlyActive 是否仅取 status=1 的角色(默认 true) * @param bool $includeGlobal 是否包含全局角色(system_id=0),默认 true * @return int[] */ private function getUserIdsByRole($roleIdOrName, ?int $systemId = null, bool $onlyActive = true, bool $includeGlobal = true): array { // 查询构造 // 情况A:纯数字 => 当作 role_id,优先直接走 user_roles(role_id) 索引 if (is_int($roleIdOrName) || (is_string($roleIdOrName) && ctype_digit($roleIdOrName))) { $roleId = (int)$roleIdOrName; // 如果不需要校验 roles 的 system/status,就不 join,性能更好 $needJoin = $onlyActive || !is_null($systemId) || $includeGlobal === false; if (!$needJoin) { $rows = Db::table('user_roles') ->where('role_id', $roleId) ->select('user_id') ->get(); } else { $q = Db::table('user_roles as ur') ->join('roles as r', 'ur.role_id', '=', 'r.id') ->where('ur.role_id', $roleId) ->select('ur.user_id'); if ($onlyActive) { $q->where('r.status', 1); } if (!is_null($systemId)) { if ($includeGlobal) { $q->whereIn('r.system_id', [0, $systemId]); } else { $q->where('r.system_id', $systemId); } } $rows = $q->get(); } } // 情况B:字符串 => 当作 roles.name else { $name = (string)$roleIdOrName; $q = Db::table('user_roles as ur') ->join('roles as r', 'ur.role_id', '=', 'r.id') ->where('r.name', $name) ->select('ur.user_id'); if ($onlyActive) { $q->where('r.status', 1); } if (!is_null($systemId)) { if ($includeGlobal) { $q->whereIn('r.system_id', [0, $systemId]); } else { $q->where('r.system_id', $systemId); } } $rows = $q->get(); } $ids = []; foreach ($rows as $row) { $ids[] = (int)$row->user_id; } return array_values(array_unique($ids)); } private function getLeaderIdByUserId(int $userId): ?int { $id = Db::table('user')->where('id', $userId)->value('report_to'); return $id ? (int)$id : null; } private function getDeptHeadId(int $deptId): ?int { $id = Db::table('departments')->where('id', $deptId)->value('head_user_id'); return $id ? (int)$id : null; } }