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)]; }); } // 流程完结后的业务回调(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()); } } /** 撤回(仅发起人在无已处理记录前可撤回) */ 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']; }); } /** 流程图数据(把 skipped/timeout 视作已完结的中性状态) */ 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) ->where('round', (int)$instance->round) ->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(); if (!empty($actions)) { $cntApproved = 0; $cntRejected = 0; $cntPending = 0; foreach ($actions as $a) { if ($a->status === 'approved') $cntApproved++; elseif ($a->status === 'rejected') $cntRejected++; elseif ($a->status === 'pending') $cntPending++; // skipped/timeout 不阻断完结显示 } if ($cntRejected > 0) $status = 'rejected'; elseif ($cntPending > 0) $status = 'processing'; elseif ($cntApproved > 0) $status = 'approved'; else $status = 'approved'; // 全是 skipped/timeout 也视为已过 } else { if ($i < (int)$instance->current_step) { 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'; $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); }); } } // ---------- 内部工具 ---------- /** 解析审批人(role/user/initiator_leader/field_user/dept_head) */ 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': $sys = isset($cfg['system_id']) ? (int)$cfg['system_id'] : null; $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; case 'group_members': { // $gid = (int)($cfg['group_id'] ?? 0); $gid = (int)($cfg['value'] ?? 0); if ($gid <= 0) throw new BusinessException('group_members 需要 group_id'); $ids = $this->getGroupMemberIds($gid); // A 方案 break; } case 'scenario_leader_of_initiator': { $formData = json_decode($instance->form_data ?? '{}', true) ?: []; // $scenario = $cfg['scenario'] ?? ($formData[$cfg['scenario_field']] ?? null); $scenario = $val ?? ($formData[$cfg['scenario_field']] ?? null); if (!$scenario) throw new BusinessException('缺少业务场景(scenario 或 scenario_field)'); $mustBelong = array_key_exists('must_belong', $cfg) ? (bool)$cfg['must_belong'] : true; $preferPrimary = array_key_exists('prefer_primary', $cfg) ? (bool)$cfg['prefer_primary'] : true; $ids = $this->getLeadersByScenarioForInitiator( (int)$instance->initiator_id, (string)$scenario, $mustBelong, $preferPrimary ); // B 方案(按场景) 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)); $resolved = array_values($resolved); } 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, null, true, true)); 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; case 'group_members': { $gid = (int)($val ?? 0); if ($gid <= 0) throw new BusinessException('group_members 需要 group_id'); $uids = $this->getGroupMemberIds($gid); // A 方案 break; } case 'scenario_leader_of_initiator': { $formData = json_decode($instance->form_data ?? '{}', true) ?: []; $scenario = $val ?? ($formData[$cc['scenario_field']] ?? null); if (!$scenario) throw new BusinessException('缺少业务场景(scenario 或 scenario_field)'); $mustBelong = array_key_exists('must_belong', $cc) ? (bool)$cc['must_belong'] : true; $preferPrimary = array_key_exists('prefer_primary', $cc) ? (bool)$cc['prefer_primary'] : true; $uids = $this->getLeadersByScenarioForInitiator( (int)$instance->initiator_id, (string)$scenario, $mustBelong, $preferPrimary ); // B 方案(按场景) break; } default: 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 = 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); } /** 条件求值(简单表达式) */ 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; } return match ($op) { '==' => $actual == $value, '!=' => $actual != $value, '>' => $actual > $value, '<' => $actual < $value, '>=' => $actual >= $value, '<=' => $actual <= $value, default => throw new BusinessException("不支持的操作符: {$op}"), }; } /** 获取下一个有效步骤索引(考虑 condition) */ 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; } /** * 聚合本步骤结论(按类型) */ private function getStepOutcome(int $instanceId, int $round, int $stepIndex, string $type): string { $records = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('round', $round) // ->where('step_index', $stepIndex) ->get(); $pending = $approved = $rejected = $timeout = $skipped = 0; foreach ($records as $r) { switch ($r->status) { case 'pending': $pending++; break; case 'approved': $approved++; break; case 'rejected': $rejected++; break; case 'timeout': $timeout++; break; case 'skipped': $skipped++; break; } } $type = strtolower($type); if ($type === 'any') { if ($rejected > 0) return 'reject'; if ($approved > 0) return 'approve'; // 全为 skipped/timeout 且无 pending/rejected ⇒ 通过 if ($pending === 0 && $rejected === 0 && $approved === 0) return 'approve'; return 'pending'; } if ($rejected > 0) return 'reject'; if ($pending > 0) return 'pending'; return 'approve'; } /** 关闭本步其余待办为 skipped */ private function closeOtherPending(int $instanceId, int $round, int $stepIndex, array $keepApproverIds = []): void { $now = date('Y-m-d H:i:s'); $q = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('round', $round) // ->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(object $instance, int $stepIndex, int $approverId): void { $round = (int)($instance->round ?? 1); $now = date('Y-m-d H:i:s'); $exists = Db::table('approval_records') ->where('instance_id', (int)$instance->id) ->where('round', $round) // 限当前轮 ->where('step_index', $stepIndex) ->where('approver_id', $approverId) ->where('status', 'pending') ->exists(); if ($exists) return; Db::table('approval_records')->insert([ 'instance_id' => (int)$instance->id, 'approver_id' => $approverId, 'step_index' => $stepIndex, 'round' => $round, // 'action' => null, 'status' => 'pending', 'comment' => null, 'approved_at' => null, 'created_at' => $now, 'updated_at' => $now, ]); } /** 发送通知(支持附加文案:提交说明/申诉/讨论摘要) */ private function sendNotification(array $userIds, object $instance, string $action, bool $isCC, array $extra = []): void { $map = [ 'pending' => '待审批', 'approve' => '已通过', 'reject' => '已拒绝', 'revoked' => '已撤回', 'voided' => '已作废', ]; $prefix = $isCC ? '【抄送】' : '【审批】'; $text = $map[$action] ?? $action; // 业务标题/内容 $approvalType = array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('approval_type')); $flowTxt = $approvalType[$instance->flow_type] ?? (string)$instance->flow_type; $title = $prefix.'审批通知'; $content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务标识 {$instance->biz_id}"; switch ($instance->flow_type) { case 'stock_orders': // 备货单 $stockOrder = \app\common\model\StockOrders::find($instance->biz_id); if ($stockOrder) { $title = $prefix."备货单:{$stockOrder->order_no}"; $flowTxt = $approvalType[$instance->flow_type] ?? $instance->flow_type; $content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$stockOrder->order_no}"; } break; case 'stock_orders_nullify': // 备货单作废 $stockOrder = \app\common\model\StockOrders::find($instance->biz_id); if ($stockOrder) { $title = $prefix."备货单作废:{$stockOrder->order_no}"; $flowTxt = $approvalType[$instance->flow_type] ?? '备货单作废'; $content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$stockOrder->order_no}"; // 把作废原因附带展示更清晰(也可依赖 $extra['note']) $fd = json_decode($instance->form_data ?? '{}', true) ?: []; if (!empty($fd['_nullify_reason'])) { $content .= ';原因:'.(function_exists('mb_strimwidth') ? mb_strimwidth($fd['_nullify_reason'], 0, 120, '...', 'UTF-8') : $fd['_nullify_reason']); } } break; case 'purchase_orders': // 采购单 $purchaseOrder = \app\common\model\PurchaseOrders::find($instance->biz_id); if ($purchaseOrder) { $title = $prefix."采购单:{$purchaseOrder->order_no}"; $flowTxt = $approvalType[$instance->flow_type] ?? $instance->flow_type; $content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$purchaseOrder->order_no}"; } break; case 'purchase_order': // 采购单 break; } // 附加说明(提交说明/申诉/讨论摘要) $append = $extra['appeal'] ?? ($extra['note'] ?? null); if ($append) { if (function_exists('mb_strimwidth')) { $append = mb_strimwidth($append, 0, 120, '...', 'UTF-8'); } $content .= ";说明:{$append}"; } noticePush([ 'title' => $title, 'content' => $content, 'recipient' => $userIds, 'email' => 1, ]); } private function notifyInitiator(object $instance, string $result): void { $this->sendNotification([(int)$instance->initiator_id], $instance, $result, false); } // ---------- 数据访问(roles / user_roles 结构) ---------- /** * 从角色取用户ID * @param mixed $roleIdOrName 数字 => role_id;字符串 => roles.name * @param int|null $systemId 限定 roles.system_id;null 不限定 * @param bool $onlyActive 仅取 status=1 角色,默认 true * @param bool $includeGlobal 是否包含全局角色(system_id=0),默认 true */ private function getUserIdsByRole($roleIdOrName, ?int $systemId = null, bool $onlyActive = true, bool $includeGlobal = true): array { if (is_int($roleIdOrName) || (is_string($roleIdOrName) && ctype_digit($roleIdOrName))) { $roleId = (int)$roleIdOrName; $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)) { $includeGlobal ? $q->whereIn('r.system_id', [0, $systemId]) : $q->where('r.system_id', $systemId); } $rows = $q->get(); } } 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)) { $includeGlobal ? $q->whereIn('r.system_id', [0, $systemId]) : $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; } /** * 查询某个审批实例的审批记录(附带步骤名/类型,可选带出审批人姓名) * * @param int $instanceId * @param string|null $userNameColumn 可选:如 'name' 或 'nickname'。传 null 则不 join 用户表 * @return array{ * instance: array, * steps: array, * records: array, * summary: array{approved:int,rejected:int,pending:int,skipped:int,timeout:int,total:int} * } */ public function getInstanceRecords(int $instanceId, ?string $userNameColumn = null): array { // 1) 实例 + 流程步骤 $instance = Db::table('approval_instances')->where('id', $instanceId)->first(); if (!$instance) { 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) ?: []; // 2) 记录查询(可选带出审批人姓名) if ($userNameColumn) { // 注意:确保 user 表存在该列 $records = Db::table('approval_records as ar') ->leftJoin('user as u', 'ar.approver_id', '=', 'u.id') ->where('ar.instance_id', $instanceId) ->orderBy('ar.step_index') ->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc') ->select( 'ar.*', Db::raw("u.`{$userNameColumn}` as approver_name") ) ->get() ->toArray(); } else { $records = Db::table('approval_records as ar') ->where('ar.instance_id', $instanceId) ->orderBy('ar.step_index') ->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc') ->select('ar.*') ->get() ->toArray(); } // 3) 附加步骤名/类型;统计汇总 $summary = ['approved'=>0,'rejected'=>0,'pending'=>0,'skipped'=>0,'timeout'=>0,'total'=>0]; foreach ($records as &$r) { $idx = (int)$r->step_index; $meta = $steps[$idx] ?? []; $r->step_name = $meta['name'] ?? ('Step '.$idx); $r->step_type = $meta['type'] ?? 'all'; if (isset($summary[$r->status])) { $summary[$r->status]++; } $summary['total']++; } unset($r); return [ 'instance' => [ 'id' => (int)$instance->id, 'flow_type' => $instance->flow_type, 'biz_type' => $instance->biz_type, 'biz_id' => $instance->biz_id, 'status' => $instance->status, 'current_step' => (int)$instance->current_step, 'created_at' => $instance->created_at, 'updated_at' => $instance->updated_at, 'finished_at' => $instance->finished_at, ], 'steps' => array_map(function($s){ return [ 'name' => $s['name'] ?? '', 'type' => $s['type'] ?? 'all', 'condition' => $s['condition'] ?? null, ]; }, $steps), 'records' => array_map(function($r){ // 转成数组(Illuminate 返回的是 stdClass) return [ 'id' => (int)$r->id, 'instance_id' => (int)$r->instance_id, 'approver_id' => (int)$r->approver_id, 'step_index' => (int)$r->step_index, 'action' => $r->action, 'status' => $r->status, 'comment' => $r->comment, 'approved_at' => $r->approved_at, 'created_at' => $r->created_at, 'updated_at' => $r->updated_at, 'approver_name'=> property_exists($r, 'approver_name') ? $r->approver_name : null, 'step_name' => $r->step_name, 'step_type' => $r->step_type, ]; }, $records), 'summary' => $summary, ]; } public function getSimpleTimeline(int $instanceId, ?string $userNameColumn = null, string $initiatorPlacement = 'prepend'): array { // 基础数据 $instance = Db::table('approval_instances')->where('id', $instanceId)->first(); if (!$instance) 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) ?: []; $curStep = (int)$instance->current_step; $round = (int)$instance->round; // 申请人 $initiatorId = (int)$instance->initiator_id; $initiatorName = null; if ($userNameColumn) { $initiatorName = Db::table('user')->where('id', $initiatorId)->value($userNameColumn); } // 审批记录(仅当前轮次) $records = Db::table('approval_records as ar') ->where('ar.instance_id', $instanceId) ->where('ar.round', $round) ->orderBy('ar.step_index') ->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc') ->select('ar.*') ->get() ->toArray(); // 一次性拉取姓名,避免 N+1 $nameMap = []; if ($userNameColumn && !empty($records)) { $uids = []; foreach ($records as $r) $uids[] = (int)$r->approver_id; $uids = array_values(array_unique($uids)); if (!empty($uids)) { $pairs = Db::table('user')->whereIn('id', $uids)->pluck($userNameColumn, 'id')->toArray(); foreach ($pairs as $uid => $name) $nameMap[(int)$uid] = $name; } } // 记录分组 $byStep = []; foreach ($records as $r) { $idx = (int)$r->step_index; $byStep[$idx][] = $r; } $timeline = []; // 可选:发起申请块 if ($initiatorPlacement === 'prepend') { $appliedAt = $instance->created_at ?: null; $timeline[] = [ 'index' => -1, 'name' => '发起申请', 'type' => '-', 'status' => 'submitted', 'started_at' => $appliedAt, 'finished_at' => $appliedAt, 'duration_seconds' => 0, 'initiator_id' => $initiatorId, 'initiator_name' => $initiatorName, ]; } // 每一步 foreach ($steps as $i => $meta) { $name = $meta['name'] ?? ('Step '.$i); $typeRaw = strtolower($meta['type'] ?? 'all'); $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; $condStr = $meta['condition'] ?? null; $condOk = $condStr ? $this->evaluateCondition($condStr, $formData) : true; $recs = $byStep[$i] ?? []; $hasRecs = !empty($recs); $status = 'pending'; $startedAt = null; $finishedAt = null; $durationSec = null; // 每一步的“审批意见”(只展示有内容的 comment) $opinions = []; // [{approver_id, approver_name?, action, status, comment, time}...] if ($hasRecs) { // 统计时间 $minCreated = null; $minApproveAt = null; $maxApproveAt = null; $minRejectAt = null; $maxTimeoutAt = null; $cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0]; foreach ($recs as $rr) { // 收集“审批意见”(非空 comment 都算) $cmt = trim((string)($rr->comment ?? '')); if ($cmt !== '') { $opinions[] = [ 'approver_id' => (int)$rr->approver_id, 'approver_name' => $userNameColumn ? ($nameMap[(int)$rr->approver_id] ?? null) : null, 'action' => $rr->action, // approve | reject | null 'status' => $rr->status, // approved|rejected|pending|skipped|timeout 'comment' => $cmt, 'time' => $rr->approved_at ?: $rr->created_at, ]; } if ($rr->created_at && (!$minCreated || $rr->created_at < $minCreated)) $minCreated = $rr->created_at; if ($rr->approved_at) { if ($rr->status === 'approved') { if (!$minApproveAt || $rr->approved_at < $minApproveAt) $minApproveAt = $rr->approved_at; if (!$maxApproveAt || $rr->approved_at > $maxApproveAt) $maxApproveAt = $rr->approved_at; } elseif ($rr->status === 'rejected') { if (!$minRejectAt || $rr->approved_at < $minRejectAt) $minRejectAt = $rr->approved_at; } elseif ($rr->status === 'timeout') { if (!$maxTimeoutAt || $rr->approved_at > $maxTimeoutAt) $maxTimeoutAt = $rr->approved_at; } } if (isset($cnt[$rr->status])) $cnt[$rr->status]++; } // 时间升序排序 opinions if (!empty($opinions)) { usort($opinions, function($a, $b) { return strcmp($a['time'] ?? '', $b['time'] ?? ''); }); } $startedAt = $minCreated; // 总结果(注意:这里按你现在的签名调用,含 round) $outcome = $this->getStepOutcome($instanceId, $round, $i, $type); // 'pending'|'approve'|'reject' if ($outcome === 'reject') { $status = 'rejected'; $finishedAt = $minRejectAt; } elseif ($outcome === 'approve') { $status = 'approved'; $finishedAt = ($type === 'any') ? $minApproveAt : $maxApproveAt; } else { if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) { $status = 'timeout'; $finishedAt = $maxTimeoutAt; } else { $status = 'processing'; } } if ($startedAt && $finishedAt) { $durationSec = max(0, strtotime($finishedAt) - strtotime($startedAt)); } } else { // 无记录:根据位置与条件展示 if ($i < $curStep) { $status = $condOk ? 'approved' : 'skipped'; } elseif ($i === $curStep && $instance->status === 'processing') { $status = 'processing'; } else { $status = $condOk ? 'pending' : 'skipped'; } } $row = [ 'index' => (int)$i, 'name' => $name, 'type' => $type, 'status' => $status, 'started_at' => $startedAt, 'finished_at' => $finishedAt, 'duration_seconds' => $durationSec, 'opinions' => $opinions, // 这就是每个审批意见 ]; // 把申请人信息并到第一步(可选) if ($initiatorPlacement === 'attach' && empty($timeline)) { $row['initiator_id'] = $initiatorId; $row['initiator_name'] = $initiatorName; } $timeline[] = $row; } return $timeline; } /** * 按业务ID获取“最新进展”的极简状态 * - 已完结:直接返回实例最终状态 * - 进行中:返回当前步骤(索引、名称、类型)与当前待处理审批者列表(pending) * * @param string $bizId * @param string|null $bizType 可选限定业务类型 * @param string|null $userNameColumn 可选:审批人姓名字段(如 user.name / nickname) * @return array{ * instance_id:int, flow_type:string, biz_type:string, biz_id:string, * status:string, // 实例状态:approved|rejected|revoked|processing * finished_at:?string, updated_at:?string, * step?: array{ * index:int, name:string, type:string, // any|all * status:string, // processing|timeout(进行中场景) * pending_approvers: array * } * } */ public function getBriefStatusByBiz(string $bizId, ?string $bizType = null, ?string $userNameColumn = null): array { // 1) 最新实例 $q = Db::table('approval_instances')->where('biz_id', $bizId); if ($bizType !== null && $bizType !== '') { $q->where('biz_type', $bizType); } $instance = $q->orderByDesc('id')->first(); if (!$instance) { // throw new BusinessException('未找到对应的审批实例'); return []; } $base = [ 'instance_id' => (int)$instance->id, 'flow_type' => $instance->flow_type, 'biz_type' => $instance->biz_type, 'biz_id' => $instance->biz_id, 'status' => $instance->status, 'finished_at' => $instance->finished_at, 'updated_at' => $instance->updated_at, ]; // 2) 已完结:直接返回 if ($instance->status !== 'processing') { return $base; } // 3) 进行中:取当前步骤 + 当前 pending 审批者 $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); if (!$flow) { // 容错:没有流程定义 return $base; } $steps = json_decode($flow->steps, true) ?: []; $idx = (int)$instance->current_step; $round = (int)$instance->round; $meta = $steps[$idx] ?? []; $typeRaw = strtolower($meta['type'] ?? 'all'); $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; // 当前步的所有记录 $recsQ = Db::table('approval_records')->where('instance_id', $instance->id)->where('step_index', $idx); // 待处理人(只列出 pending) if ($userNameColumn) { $pendingRows = Db::table('approval_records as ar') ->leftJoin('user as u', 'ar.approver_id', '=', 'u.id') ->where('ar.instance_id', $instance->id) ->where('ar.round', $round) ->where('ar.step_index', $idx) ->where('ar.status', 'pending') ->select('ar.approver_id', Db::raw("u.`{$userNameColumn}` as approver_name")) ->get() ->toArray(); } else { $pendingRows = Db::table('approval_records') ->where('instance_id', $instance->id) ->where('round', $round) ->where('step_index', $idx) ->where('status', 'pending') ->select('approver_id') ->get() ->toArray(); } $pendingApprovers = []; foreach ($pendingRows as $r) { $row = ['approver_id' => (int)$r->approver_id]; if ($userNameColumn && property_exists($r, 'approver_name')) { $row['approver_name'] = $r->approver_name; } $pendingApprovers[] = $row; } // 当前步的聚合状态(进行中通常返回 processing;若全被超时任务置为 timeout,则返回 timeout) // 先快速统计是否纯 timeout $stats = Db::table('approval_records') ->where('instance_id', $instance->id) ->where('round', $round) ->where('step_index', $idx) ->selectRaw("SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) as c_pending") ->selectRaw("SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as c_approved") ->selectRaw("SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as c_rejected") ->selectRaw("SUM(CASE WHEN status='timeout' THEN 1 ELSE 0 END) as c_timeout") ->first(); $stepStatus = 'processing'; if ($stats && (int)$stats->c_timeout > 0 && (int)$stats->c_pending === 0 && (int)$stats->c_approved === 0 && (int)$stats->c_rejected === 0) { $stepStatus = 'timeout'; } $base['step'] = [ 'index' => $idx, 'name' => $meta['name'] ?? ('Step '.$idx), 'type' => $type, // any|all 'status' => $stepStatus, // processing|timeout 'pending_approvers' => $pendingApprovers, // 当前正在等待的审批者 ]; return $base; } // ====== ====== ====== ====== ====== ====== 重提操作 && 讨论 ====== ====== ====== ====== ====== ====== /** * 重提交(仅对 status=rejected 的实例;支持申诉说明) * - 合并 $patchData 到 form_data,并设置 resubmitted=1 * - 若 $appealText 非空:form_data.appeal_last = $appealText(可用于前端展示) * - 重新计算首个命中的步骤并创建待办;通知审批人且带上申诉说明 */ 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), ]; }); } /** 是否该实例参与者(发起人/任一步审批人/抄送人) */ private function isParticipantOfInstance(int $instanceId, int $userId): bool { $inst = Db::table('approval_instances')->where('id', $instanceId)->first(); if (!$inst) return false; if ((int)$inst->initiator_id === $userId) return true; $inRecords = Db::table('approval_records') ->where('instance_id', $instanceId)->where('approver_id', $userId)->exists(); if ($inRecords) return true; $inCc = Db::table('approval_ccs') ->where('instance_id', $instanceId)->where('user_id', $userId)->exists(); return $inCc; } /** 是否该实例任一步的审批人 */ private function isApproverOfInstance(int $instanceId, int $userId): bool { return Db::table('approval_records') ->where('instance_id', $instanceId) ->where('approver_id', $userId) ->exists(); } /** 获取讨论受众:公开=全体参与者;内部=仅审批人(排除自己) */ private function getDiscussionAudienceUserIds(int $instanceId, bool $approversOnly, int $excludeUserId): array { $uids = []; if (!$approversOnly) { $inst = Db::table('approval_instances')->where('id', $instanceId)->first(); if ($inst) $uids[] = (int)$inst->initiator_id; $ccs = Db::table('approval_ccs')->where('instance_id', $instanceId)->pluck('user_id')->toArray(); foreach ($ccs as $u) $uids[] = (int)$u; } $approvers = Db::table('approval_records') ->where('instance_id', $instanceId) ->distinct()->pluck('approver_id')->toArray(); foreach ($approvers as $u) $uids[] = (int)$u; $uids = array_values(array_unique(array_filter($uids, fn($u) => $u !== $excludeUserId))); return $uids; } /** * 发表讨论 * @param int $instanceId * @param int $userId * @param string $content * @param bool $isInternal true=仅审批人可见;false=所有参与者可见 * @param array $attachments 可选附件数组 * @param string|null $userNameColumn 通知里带发言人姓名(如 'name'),不需要传 null */ public function postDiscussion( int $instanceId, int $userId, string $content, bool $isInternal = false, array $attachments = [], ?string $userNameColumn = null ): array { $content = trim($content); if ($content === '') throw new BusinessException('讨论内容不能为空'); if (!$this->isParticipantOfInstance($instanceId, $userId)) { throw new BusinessException('无权参与该实例的讨论'); } if ($isInternal && !$this->isApproverOfInstance($instanceId, $userId)) { throw new BusinessException('仅审批人可发布内部讨论'); } $now = date('Y-m-d H:i:s'); $id = Db::table('approval_discussions')->insertGetId([ 'instance_id' => $instanceId, 'user_id' => $userId, 'is_internal' => $isInternal ? 1 : 0, 'content' => $content, 'attachments' => empty($attachments) ? null : json_encode($attachments, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES), 'created_at' => $now, 'updated_at' => $now, ]); // 通知其他人 $inst = Db::table('approval_instances')->where('id', $instanceId)->first(); if ($inst) { $audience = $this->getDiscussionAudienceUserIds($instanceId, $isInternal, $userId); // 发言人姓名(可选) $speaker = (string)$userId; if ($userNameColumn) { $name = Db::table('user')->where('id', $userId)->value($userNameColumn); if ($name) $speaker = $name; } $snippet = function_exists('mb_strimwidth') ? mb_strimwidth($content, 0, 80, '...', 'UTF-8') : $content; $extra = ['note' => "【讨论】{$speaker}:{$snippet}"]; $this->sendNotification( $audience, (object)[ 'id' => (int)$inst->id, 'flow_type' => (string)$inst->flow_type, 'initiator_id' => (int)$inst->initiator_id, 'form_data' => $inst->form_data, 'biz_type' => (string)$inst->biz_type, 'biz_id' => (string)$inst->biz_id, ], 'pending', false, $extra ); } return [ 'id' => (int)$id, 'instance_id' => $instanceId, 'user_id' => $userId, 'is_internal' => (bool)$isInternal, 'content' => $content, 'attachments' => $attachments, 'created_at' => $now, ]; } /** * 获取讨论列表 * @param int $instanceId * @param int|null $afterId 翻页:取 id > afterId 的新消息 * @param int $limit * @param string|null $userNameColumn 带出用户名(如 'name'),不需要可传 null */ public function listDiscussions(int $instanceId, ?int $afterId = null, int $limit = 50, ?string $userNameColumn = null): array { $q = Db::table('approval_discussions as d')->where('d.instance_id', $instanceId); if ($afterId) $q->where('d.id', '>', $afterId); $q->orderBy('d.id', 'asc')->limit(max(1, min(200, $limit))); if ($userNameColumn) { $rows = $q->leftJoin('user as u', 'd.user_id', '=', 'u.id') ->select('d.*', Db::raw("u.`{$userNameColumn}` as user_name")) ->get()->toArray(); } else { $rows = $q->select('d.*')->get()->toArray(); } return array_map(function($r){ return [ 'id' => (int)$r->id, 'instance_id' => (int)$r->instance_id, 'user_id' => (int)$r->user_id, 'is_internal' => (bool)$r->is_internal, 'content' => $r->content, 'attachments' => $r->attachments ? json_decode($r->attachments, true) : [], 'created_at' => $r->created_at, 'updated_at' => $r->updated_at, 'user_name' => property_exists($r, 'user_name') ? $r->user_name : null, ]; }, $rows); } // ====== ====== ====== ====== ====== ====== 转交操作 ====== ====== ====== ====== ====== ====== /** * 查看某个实例的转交流水(按时间升序) */ public function listTransfers(int $instanceId): array { $rows = Db::table('approval_transfers') ->where('instance_id', $instanceId) ->orderBy('id', 'asc') ->get() ->toArray(); return array_map(function($r){ return [ 'id' => (int)$r->id, 'instance_id' => (int)$r->instance_id, 'step_index' => (int)$r->step_index, 'record_id' => (int)$r->record_id, 'from_user_id' => (int)$r->from_user_id, 'to_user_id' => (int)$r->to_user_id, 'reason' => $r->reason, 'created_at' => $r->created_at, ]; }, $rows); } /** * 转交当前待办给他人(原地替换 approver_id)并在通知里追加“来源/原因” */ public function transferApproval( int $instanceId, int $stepIndex, int $fromUserId, int $toUserId, string $reason = '', ?string $userNameColumn = null // 可选:传 'name' 或 'nickname' 来展示姓名 ): array { if ($fromUserId === $toUserId) { return ['status' => 'noop', 'msg' => '目标与来源相同,无需转交']; } return \support\Db::transaction(function () use ($instanceId, $stepIndex, $fromUserId, $toUserId, $reason, $userNameColumn) { // 锁实例,避免并发推进 $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); if (!$instance) throw new BusinessException('审批实例未找到'); if ($instance->status !== 'processing') throw new BusinessException('流程非进行中,无法转交'); // 锁定来源人的 pending 记录 $record = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('round', (int)$instance->round) ->where('step_index', $stepIndex) ->where('approver_id', $fromUserId) ->where('status', 'pending') ->lockForUpdate() ->first(); if (!$record) throw new BusinessException('未找到可转交的待办或已处理'); $now = date('Y-m-d H:i:s'); // 若目标已有 pending:合并(本记录置为 skipped) $target = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('round', (int)$instance->round) ->where('step_index', $stepIndex) ->where('approver_id', $toUserId) ->where('status', 'pending') ->lockForUpdate() ->first(); if ($target) { Db::table('approval_records')->where('id', $record->id)->update([ 'status' => 'skipped', 'approved_at' => $now, 'updated_at' => $now, 'comment' => trim(($record->comment ?? '')."\n[转交给 {$toUserId},目标已有待办,已合并]"), ]); $effectiveRecordId = (int)$target->id; } else { // 原地替换 approver_id Db::table('approval_records')->where('id', $record->id)->update([ 'approver_id' => $toUserId, 'updated_at' => $now, 'comment' => trim(($record->comment ?? '')."\n[自 {$fromUserId} 转交]"), ]); $effectiveRecordId = (int)$record->id; } // 组织“转交来源/原因”文案(优先姓名) $fromLabel = (string)$fromUserId; $toLabel = (string)$toUserId; if ($userNameColumn) { $fromName = Db::table('user')->where('id', $fromUserId)->value($userNameColumn); $toName = Db::table('user')->where('id', $toUserId)->value($userNameColumn); if ($fromName) $fromLabel = $fromName; if ($toName) $toLabel = $toName; } $note = "【转交】{$fromLabel} → {$toLabel}"; $r = trim($reason); if ($r !== '') $note .= ",原因:{$r}"; // 通知被转交人(在内容里追加 note) $this->sendNotification([$toUserId], (object)[ 'id' => (int)$instance->id, 'flow_type' => (string)$instance->flow_type, 'biz_type' => (string)$instance->biz_type, 'biz_id' => (string)$instance->biz_id, 'initiator_id' => (int)$instance->initiator_id, 'form_data' => $instance->form_data, ], 'pending', false, ['note' => $note]); // 记转交流水 Db::table('approval_transfers')->insert([ 'instance_id' => (int)$instanceId, 'step_index' => (int)$stepIndex, 'record_id' => $effectiveRecordId, 'from_user_id' => (int)$fromUserId, 'to_user_id' => (int)$toUserId, 'reason' => $reason, 'created_at' => $now, ]); $this->pushTodoChanged([$fromUserId, $toUserId]); return ['status' => 'ok', 'record_id' => $effectiveRecordId]; }); } /** 新增:发起“作废审批” */ public function startNullify( string $voidFlowType, // 例如 'stock_orders_nullify' string $bizType, // 原业务类型,如 'stock_orders' string $bizId, // 原业务ID array $formData, // 可包含 ['nullify_reason'=>...] 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]; }); } // A) 取组内全部成员 private function getGroupMemberIds(int $groupId): array { $rows = Db::table('user_group_members') ->where('group_id', $groupId) ->where('status', 1) ->pluck('user_id')->toArray(); return array_values(array_unique(array_map('intval', $rows))); } // B) 按“场景”在发起人所在的组里找负责人(支持主归属/排序兜底) private function getLeadersByScenarioForInitiator( int $initiatorId, string $scenarioCode, bool $mustBelong = true, bool $preferPrimary = true ): array { // 找出“该场景下发起人所在的所有组” $groups = Db::table('user_group as g') ->join('user_group_members as m', 'm.group_id', '=', 'g.id') ->where('g.scenario_code', $scenarioCode) ->where('g.status', 1) ->where('m.user_id', $initiatorId) ->where('m.status', 1) ->orderByDesc('m.is_primary') // 主归属优先 ->orderByDesc('g.sort') // 其次按组排序 ->select('g.id', 'm.is_primary') ->get()->toArray(); if (empty($groups)) { if ($mustBelong) { throw new BusinessException('发起人在该业务场景下未加入任何分组'); } // 不强制属于:允许只按“该场景下任一组”的负责人(不推荐) $groups = Db::table('user_group as g') ->where('g.scenario_code', $scenarioCode) ->where('g.status', 1) ->orderByDesc('g.sort') ->select('g.id', Db::raw('0 as is_primary')) ->get()->toArray(); if (empty($groups)) { throw new BusinessException('该业务场景下未配置任何分组'); } } // 选择一个“目标组” $targetGroupId = null; if ($preferPrimary) { foreach ($groups as $g) { if ((int)$g->is_primary === 1) { $targetGroupId = (int)$g->id; break; } } } if (!$targetGroupId) { $targetGroupId = (int)$groups[0]->id; // 用排序第一的组 } // 取负责人(可支持多负责人) $leaders = Db::table('user_group_members') ->where('group_id', $targetGroupId) ->where('is_leader', 1) ->where('status', 1) ->pluck('user_id')->toArray(); $ids = array_values(array_unique(array_map('intval', $leaders))); if (empty($ids)) throw new BusinessException('目标分组未配置负责人'); return $ids; } // 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; } /** WebSocket:把一组用户的“待办数量”推送给前端 */ private function pushTodoChanged22(array $userIds): void { $userIds = array_values(array_unique(array_map('intval', $userIds))); if (empty($userIds)) return; // 统计这些用户当前的待办数量(以 approval_records.status = 'pending' 为准) $rows = Db::table('approval_records') ->whereIn('approver_id', $userIds) ->where('status', 'pending') ->groupBy('approver_id') ->select('approver_id', Db::raw('COUNT(*) as cnt')) ->get(); // 默认都置 0,再填充真实值 $payload = array_fill_keys($userIds, 0); foreach ($rows as $r) { $payload[(int)$r->approver_id] = (int)$r->cnt; } try { foreach ($payload as $uid => $cnt) { $data = [ 'event' => 'approval.todo.counts', 'user_id' => $uid, 'num' => $cnt, ]; \Webman\RedisQueue\Redis::send('ws-msg', $data); } } catch (\Throwable $e) { Log::error('ws todo push error: '.$e->getMessage()); } } /** 取“某实例、某轮次、某步骤”里当前 pending 的审批人(用于推送受影响的用户) */ private function getPendingApproverIds(int $instanceId, int $round, int $stepIndex): array { $ids = Db::table('approval_records') ->where('instance_id', $instanceId) ->where('round', $round) ->where('step_index', $stepIndex) ->where('status', 'pending') ->pluck('approver_id')->toArray(); return array_values(array_unique(array_map('intval', $ids))); } /** 登录/首屏初始化:获取当前用户的待办总数(仅统计当前步骤、进行中的实例) */ public function getInitTodoCount(int $userId): int { $userId = (int)$userId; if ($userId <= 0) { throw new BusinessException('无效的用户ID'); } $row = Db::table('approval_records as ar') ->join('approval_instances as ai', 'ai.id', '=', 'ar.instance_id') ->where('ar.approver_id', $userId) ->where('ar.status', 'pending') ->where('ai.status', 'processing') // 只统计实例“当前轮次、当前步骤”的待办,避免历史残留干扰 ->whereRaw('ar.round = ai.round') ->whereRaw('ar.step_index = ai.current_step') ->selectRaw('COUNT(*) as c') ->first(); return (int)($row->c ?? 0); } /** WebSocket:把一组用户的“待办数量”推送给前端(与 getInitTodoCount 统一口径) */ private function pushTodoChanged(array $userIds): void { $userIds = array_values(array_unique(array_map('intval', $userIds))); if (empty($userIds)) return; // 只统计:实例进行中 + 当前轮次 + 当前步骤 的 pending $rows = Db::table('approval_records as ar') ->join('approval_instances as ai', 'ai.id', '=', 'ar.instance_id') ->whereIn('ar.approver_id', $userIds) ->where('ar.status', 'pending') ->where('ai.status', 'processing') ->whereRaw('ar.round = ai.round') ->whereRaw('ar.step_index = ai.current_step') ->groupBy('ar.approver_id') ->select('ar.approver_id', Db::raw('COUNT(*) as cnt')) ->get(); // 默认置 0,再填充真实值 $payload = array_fill_keys($userIds, 0); foreach ($rows as $r) { $payload[(int)$r->approver_id] = (int)$r->cnt; } try { foreach ($payload as $uid => $cnt) { (new \app\common\utils\WsEvents())->todoCounts($uid, $cnt); } } catch (\Throwable $e) { Log::error('ws todo push error: '.$e->getMessage()); } } }