Files
fdesk/app/common/utils/approval/service/ApprovalFlowService_bak.php
T
2025-09-09 13:42:00 +08:00

751 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\utils\approval\service;
use support\exception\BusinessException;
use support\Db;
/**
* 审批流程服务(Webman + Illuminate DB)
* - 支持条件步骤、会签(any/all)、并发安全(事务+锁)、抄送、超时处理
* - 统一状态:
* 实例 status: processing | approved | rejected | revoked | timed_out
* 记录 status: pending | approved | rejected | skipped | timeout
* 记录 action: approve | reject | null
*/
class ApprovalFlowService_bak
{
// === 对外方法 ===
/**
* 启动审批流程
*/
public function start(string $flowType, string $bizType, string $bizId, array $formData, int $initiatorId): array
{
return Db::transaction(function () use ($flowType, $bizType, $bizId, $formData, $initiatorId) {
// 1) 流程定义
$flow = Db::table('approval_flows')
->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;
}
}