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("流程步骤配置错误"); // 找首个有效步骤索引(考虑条件) $firstIndex = $this->getNextStepIndex($steps, $formData, -1); if ($firstIndex === null) throw new BusinessException("未找到可执行的审批步骤"); $firstStep = $steps[$firstIndex]; $now = date('Y-m-d H:i:s'); $deadlineAt = null; if (($flow->timeout_hours ?? 0) > 0) { $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); } $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, 'round' => 1, 'deadline_at' => $deadlineAt, 'created_at' => $now, 'updated_at' => $now, ]); $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', 'flow_type' => $flowType, 'biz_type' => $bizType, 'biz_id' => $bizId, 'round' => 1, ]; // 解析首步审批人(支持 role_id 或 roles.name) $approverIds = $this->resolveApprovers($firstStep, $instance, true); foreach ($approverIds as $uid) { $this->insertApprovalRecord($instance, $firstIndex, $uid); } $extra = []; $submitNote = $formData['submit_note'] ?? ''; if (is_string($submitNote) && $submitNote !== '') { $extra['note'] = $submitNote; } $this->sendNotification($approverIds, $instance, 'pending', false, $extra); // $this->notifyCCs($firstStep, $instance, 'pending'); $this->pushTodoChanged($approverIds); 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') { return ['status' => 'noop', 'msg' => '实例未处理']; } $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; $round = (int)$instance->round; $step = $steps[$idx] ?? null; if (!$step) throw new BusinessException("当前步骤配置错误"); // 找到该用户在本步的 pending 记录(加锁) $pendingRecord = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('round', $round) ->where('approver_id', $approverId) ->where('step_index', $idx) ->where('status', 'pending') ->lockForUpdate() ->first(); if (!$pendingRecord) { // 已处理过? $anyRecord = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('round', $round) ->where('approver_id', $approverId) ->where('step_index', $idx) ->first(); if ($anyRecord && $anyRecord->status !== 'pending') { return ['status' => 'noop', 'msg' => '已处理']; } // 步骤已推进 if ((int)$instance->current_step !== $idx) { return ['status' => 'noop', 'msg' => '非当前审批']; } return ['status' => 'noop', 'msg' => '无待处理记录']; } $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, ]); // 异步审批回调 9.23 add $approvalcallback = [ 'biz_id' => $instance->biz_id, 'flow_type' => $instance->flow_type, 'instance_id' => $instance->id, 'initiator_id' => $instance->initiator_id, 'status' => $action ]; \Webman\RedisQueue\Redis::send('approval-callback', $approvalcallback); // 2) 聚合本步结论(any / all) $typeRaw = strtolower($step['type'] ?? 'all'); $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; $outcome = $this->getStepOutcome($instanceId, $round, $idx, $type); // 'pending' | 'approve' | 'reject' if ($outcome === 'pending') { $this->pushTodoChanged([$approverId]); return ['status' => 'step_pending', 'msg' => '审批待定']; } $otherPendingUids = $this->getPendingApproverIds($instanceId, $round, $idx); // 3) 有结论:关闭本步其余 pending $this->closeOtherPending($instanceId, $round, $idx, [$approverId]); // 这些人 pending→skipped,待办数减少 $this->pushTodoChanged($otherPendingUids); // 4) 拒绝 => 结束 if ($outcome === 'reject') { Db::table('approval_instances')->where('id', $instanceId)->update([ 'status' => 'rejected', 'finished_at' => $now, 'updated_at' => $now, ]); $this->onFlowFinalized($instance, 'rejected'); $this->sendNotification([$instance->initiator_id], $instance, 'reject', false); $this->pushTodoChanged([$approverId]); return ['status' => 'completed', 'msg' => '拒绝']; } // 5) 通过 => 进入下一步或结束 $formData = json_decode($instance->form_data, true) ?: []; $nextIndex = $this->getNextStepIndex($steps, $formData, $idx); if ($nextIndex === null) { $finalStatus = $this->normalizeFinalStatus((string)$instance->flow_type, 'approved'); $notifyAction = ($finalStatus === 'voided') ? 'voided' : 'approve'; Db::table('approval_instances')->where('id', $instanceId)->update([ 'status' => $finalStatus, 'finished_at' => $now, 'updated_at' => $now, ]); $this->onFlowFinalized($instance, $finalStatus); // 新增:在流程最终完成时才发本步骤的抄送 if (!empty($step['cc'])) { $this->notifyCCs($step, $instance, $notifyAction); } $this->sendNotification([$instance->initiator_id], $instance, $notifyAction, false); $this->pushTodoChanged([$approverId]); return ['status' => 'completed', 'msg' => ($finalStatus === 'voided' ? '已作废' : '同意')]; } // 乐观推进 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) { return ['status' => 'noop', 'msg' => '并发进展']; } // 生成下一步待办 + 通知 $nextStep = $steps[$nextIndex]; $nextApprovers = $this->resolveApprovers($nextStep, $instance, true); foreach ($nextApprovers as $uid) { $this->insertApprovalRecord($instance, $nextIndex, $uid); } $this->sendNotification($nextApprovers, $instance, 'pending', false); // $this->notifyCCs($nextStep, $instance, 'pending'); $this->pushTodoChanged(array_merge([$approverId], $nextApprovers)); return ['status' => 'next_step', 'msg' => '进行下一步:'.$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, ]); $this->onFlowFinalized($instance, 'revoked'); $pendingUids = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('status', 'pending') ->pluck('approver_id')->toArray(); 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); $this->pushTodoChanged($pendingUids); return ['status' => 'completed', 'result' => 'revoked']; }); } /** 定时任务:处理超时(实例级) */ 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'; $final = $this->normalizeFinalStatus((string)$instance->flow_type, $final); $finalAction = ($final === 'voided') ? 'voided' : (($final === 'approved') ? 'approve' : 'reject'); Db::table('approval_instances')->where('id', $instance->id)->update([ 'status' => $final, 'finished_at' => $now, 'updated_at' => $now, ]); $this->onFlowFinalized((object)$instance, $final); $pendingUids = Db::table('approval_records') ->where('instance_id', $instance->id) ->where('round', (int)$instance->round) ->where('step_index', $instance->current_step) ->where('status', 'pending') ->pluck('approver_id')->toArray(); Db::table('approval_records') ->where('instance_id', $instance->id) ->where('round', (int)$instance->round) ->where('step_index', $instance->current_step) ->where('status', 'pending') ->update(['status' => 'timeout', 'approved_at' => $now, 'updated_at' => $now]); $this->notifyInitiator($instance, $finalAction); $this->pushTodoChanged($pendingUids); }); } } /** 重提交(仅对 status=rejected 的实例;支持申诉说明) */ public function resubmit(int $instanceId, int $initiatorId, array $patchData = [], string $appealText = ''): array { return Db::transaction(function () use ($instanceId, $initiatorId, $patchData, $appealText) { $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 !== 'rejected') 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) ?: []; $formData = json_decode($instance->form_data ?? '{}', true) ?: []; // 合并改动 & 打上重提标记 $formData = array_merge($formData, $patchData); $formData['resubmitted'] = 1; $appealText = trim((string)$appealText); if ($appealText !== '') { $formData['appeal_last'] = $appealText; } // 计算重提后首个命中的步骤 $firstIndex = $this->getNextStepIndex($steps, $formData, -1); if ($firstIndex === null) throw new BusinessException('重提后未找到可执行的步骤'); $now = date('Y-m-d H:i:s'); $deadlineAt = null; if (($flow->timeout_hours ?? 0) > 0) { $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); } $oldPendingUids = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('status', 'pending') ->pluck('approver_id')->toArray(); // 清理可能遗留的 pending Db::table('approval_records') ->where('instance_id', $instanceId) ->where('status', 'pending') ->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]); // 轮次 +1 $newRound = ((int)$instance->round) + 1; // 重新开启实例 Db::table('approval_instances')->where('id', $instanceId)->update([ 'status' => 'processing', 'current_step' => $firstIndex, 'round' => $newRound, 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'finished_at' => null, 'deadline_at' => $deadlineAt, 'updated_at' => $now, ]); // 创建待办 + 通知 $firstStep = $steps[$firstIndex]; $instanceLite = (object)[ 'id' => (int)$instanceId, 'flow_type' => (string)$instance->flow_type, 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'initiator_id' => (int)$instance->initiator_id, 'biz_type' => (string)$instance->biz_type, 'biz_id' => (string)$instance->biz_id, 'round' => $newRound, ]; $approvers = $this->resolveApprovers($firstStep, $instanceLite, true); if (empty($approvers)) throw new BusinessException('重提后首步未解析到审批人'); foreach ($approvers as $uid) { $this->insertApprovalRecord($instanceLite, $firstIndex, (int)$uid); } $extra = $appealText !== '' ? ['appeal' => $appealText] : []; $this->sendNotification($approvers, $instanceLite, 'pending', false, $extra); $this->notifyCCs($firstStep, $instanceLite, 'pending'); $this->pushTodoChanged(array_merge($oldPendingUids, $approvers)); return [ 'status' => 'resubmitted', 'instance_id' => (int)$instanceId, 'current_step' => $firstIndex, 'step_name' => $firstStep['name'] ?? ('Step '.$firstIndex), ]; }); } /** 新增:发起“作废审批” */ public function startNullify( string $voidFlowType, string $bizType, string $bizId, array $formData, int $initiatorId ): array { return Db::transaction(function () use ($voidFlowType, $bizType, $bizId, $formData, $initiatorId) { // 1) 流程定义校验 $flow = Db::table('approval_flows')->where('type', $voidFlowType)->where('is_active', 1)->first(); if (!$flow) throw new BusinessException("作废流程未找到或已停用: {$voidFlowType}"); $steps = json_decode($flow->steps, true); if (!is_array($steps) || empty($steps)) throw new BusinessException("作废流程步骤配置错误"); // 2) 必须存在“已通过”的最新有效实例(非作废流程) $latestApproved = Db::table('approval_instances') ->where('biz_type', $bizType) ->where('biz_id', $bizId) ->where('flow_type', '!=', $voidFlowType) ->where('status', 'approved') ->orderByDesc('id') ->first(); if (!$latestApproved) { throw new BusinessException('仅已通过的单据可发起作废'); } // 3) 不允许并行的作废流程 $voidProcessing = Db::table('approval_instances') ->where('flow_type', $voidFlowType) ->where('biz_id', $bizId) ->where('status', 'processing') ->exists(); if ($voidProcessing) { throw new BusinessException('已存在进行中的作废审批'); } // A. 基于历史“作废流程已通过”的实例拦截 $voidApproved = Db::table('approval_instances') ->where('flow_type', $voidFlowType) ->where('biz_id', $bizId) ->whereIn('status', ['approved','voided']) ->exists(); if ($voidApproved) { throw new BusinessException('该单据已作废,无需重复作废'); } // B.(可选)直接查业务表状态拦截(若你用 approval_status=4 表示已作废) /* $orderVoided = \app\common\model\StockOrders::where('id', $bizId) ->where('approval_status', 4) ->exists(); if ($orderVoided) { throw new BusinessException('该单据已作废,无需重复作废'); } */ // 4) 计算首步 // 注:作废流程可以有独立条件;若不需要条件,直接第一步 $formData = array_merge([ '_op' => 'nullify', '_nullify_reason' => $formData['nullify_reason'] ?? ($formData['reason'] ?? ''), '_nullify_of_instance_id' => (int)$latestApproved->id, ], $formData); $firstIndex = $this->getNextStepIndex($steps, $formData, -1); if ($firstIndex === null) throw new BusinessException("作废流程未命中任何审批步骤"); $firstStep = $steps[$firstIndex]; $now = date('Y-m-d H:i:s'); $deadlineAt = null; if (($flow->timeout_hours ?? 0) > 0) { $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); } // 5) 新建作废实例(round 固定从 1 开始) $instanceId = Db::table('approval_instances')->insertGetId([ 'flow_type' => $voidFlowType, '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, 'round' => 1, // 'deadline_at' => $deadlineAt, 'created_at' => $now, 'updated_at' => $now, ]); $instance = (object)[ 'id' => $instanceId, 'flow_type' => $voidFlowType, 'biz_type' => $bizType, 'biz_id' => $bizId, 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'initiator_id' => $initiatorId, 'current_step' => $firstIndex, 'status' => 'processing', 'round' => 1, // ]; // 6) 生成首步待办 + 通知(带上作废原因) $approverIds = $this->resolveApprovers($firstStep, $instance, true); foreach ($approverIds as $uid) { $this->insertApprovalRecord($instance, $firstIndex, $uid); } $extra = []; if (!empty($formData['_nullify_reason'])) { $extra['note'] = '【作废原因】'.$formData['_nullify_reason']; } $this->sendNotification($approverIds, $instance, 'pending', false, $extra); $this->notifyCCs($firstStep, $instance, 'pending'); $this->pushTodoChanged($approverIds); return ['instance_id' => $instanceId]; }); } // 流程完结后的业务回调(approved|rejected|revoked|timeout?) private function onFlowFinalized(object $instance, string $final): void { try { switch ($instance->flow_type) { case 'stock_orders': { $order = \app\common\model\StockOrders::find($instance->biz_id); if (!$order) { throw new BusinessException('订单不存在'); } if ($final === 'approved') { $order->approval_status = 2; $order->save(); } elseif ($final === 'rejected') { $order->approval_status = 3; $order->save(); } elseif ($final === 'revoked') { $order->approval_status = 0; $order->save(); } break; } case 'stock_orders_nullify': if ($final === 'approved') { $final = 'voided'; Db::table('approval_instances')->where('id', $instance->id)->update([ 'status' => 'voided', ]); } if (in_array($final, ['voided','approved'], true)) { $order = \app\common\model\StockOrders::find($instance->biz_id); if ($order && $order->approval_status !== 4) { $order->approval_status = 4; $order->save(); } } break; default: // 非作废流程,无需处理 break; } } catch (\Throwable $e) { Log::error('onFlowFinalized error: '.$e->getMessage()); } } // 是否作废流程 private function isNullifyFlow(string $flowType): bool { $t = strtolower(trim($flowType)); return str_ends_with($t, '_nullify') || in_array($t, [ // 所有“作废”类流程的类型 'stock_orders_nullify', ], true); } // 归一化最终状态 private function normalizeFinalStatus(string $flowType, string $final): string { if ($this->isNullifyFlow($flowType) && $final === 'approved') { return 'voided'; } return $final; } }