Files
fdesk/app/common/utils/approval/service/ApprovalFlowService.php
T
2025-09-09 17:30:39 +08:00

1141 lines
48 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 app\common\service\MessageNotificationsService;
use support\exception\BusinessException;
use support\Db;
class ApprovalFlowService
{
/** 启动审批流程 */
public function start(string $flowType, string $bizType, string $bizId, array $formData, int $initiatorId): array
{
return Db::transaction(function () use ($flowType, $bizType, $bizId, $formData, $initiatorId) {
$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("流程步骤配置错误");
// 找首个有效步骤索引(考虑条件)
$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,
'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',
];
// 解析首步审批人(支持 role_id 或 roles.name)
$approverIds = $this->resolveApprovers($firstStep, $instance, true);
foreach ($approverIds as $uid) {
$this->insertApprovalRecord($instanceId, $firstIndex, $uid);
}
$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') {
// return ['status' => 'noop', 'reason' => 'instance_not_processing', 'instance_status' => $instance->status];
// throw new BusinessException("实例未处理");
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;
$step = $steps[$idx] ?? null;
if (!$step) throw new BusinessException("当前步骤配置错误");
// 找到该用户在本步的 pending 记录(加锁)
$pendingRecord = Db::table('approval_records')
->where('instance_id', $instanceId)
->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('approver_id', $approverId)
->where('step_index', $idx)
->first();
if ($anyRecord && $anyRecord->status !== 'pending') {
// return ['status' => 'noop', 'reason' => 'already_handled', 'record_status' => $anyRecord->status];
// throw new BusinessException("已处理");
return ['status' => 'noop', 'msg' => '已处理'];
}
// 步骤已推进
if ((int)$instance->current_step !== $idx) {
// return ['status' => 'noop', 'reason' => 'not_current_step', 'current_step' => (int)$instance->current_step];
// throw new BusinessException("非当前步骤");
return ['status' => 'noop', 'msg' => '非当前步骤'];
}
// return ['status' => 'noop', 'reason' => 'no_pending_record'];
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,
]);
// 2) 聚合本步结论(any / all)
$typeRaw = strtolower($step['type'] ?? 'all');
$type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all';
$outcome = $this->getStepOutcome($instanceId, $idx, $type); // 'pending' | 'approve' | 'reject'
if ($outcome === 'pending') {
// return ['status' => 'step_pending'];
return ['status' => 'step_pending', 'msg' => '步骤待定'];
}
// 3) 有结论:关闭本步其余 pending
$this->closeOtherPending($instanceId, $idx, [$approverId]);
// 4) 拒绝 => 结束
if ($outcome === 'reject') {
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'];
return ['status' => 'completed', 'msg' => '已拒绝'];
}
// 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'];
return ['status' => 'completed', 'msg' => '已同意'];
}
// 乐观推进 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', 'reason' => 'concurrent_progressed'];
return ['status' => 'noop', 'msg' => '并发进展'];
}
// 生成下一步待办 + 通知
$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)];
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,
]);
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/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)
->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';
$finalAction = ($flow->timeout_action === 'auto_approve') ? 'approve' : 'reject';
Db::table('approval_instances')->where('id', $instance->id)->update([
'status' => $final,
'finished_at' => $now,
'updated_at' => $now,
]);
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);
});
}
}
// ---------- 内部工具 ----------
/** 解析审批人(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;
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, 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;
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;
}
/**
* 聚合本步骤结论(按类型)
* 返回:'pending' | 'approve' | 'reject'
*
* any:有 reject => reject;否则有 approve => approve;否则 pending
* all:有 reject => reject;有 pending => pending;否则(全 approved 或 approved+skipped/timeout)=> approve
*/
private function getStepOutcome(int $instanceId, int $stepIndex, string $type): string
{
$records = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('step_index', $stepIndex)
->get();
$pending = 0; $approved = 0; $rejected = 0;
foreach ($records as $r) {
if ($r->status === 'pending') { $pending++; }
elseif ($r->status === 'approved'){ $approved++; }
elseif ($r->status === 'rejected'){ $rejected++; }
// skipped/timeout 不影响聚合结论
}
$type = strtolower($type);
if ($type === 'any') {
if ($rejected > 0) return 'reject';
if ($approved > 0) return 'approve';
return 'pending';
}
// all
if ($rejected > 0) return 'reject';
if ($pending > 0) return 'pending';
return $approved > 0 ? 'approve' : 'pending';
}
/** 关闭本步其余待办为 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',
'comment' => null,
'approved_at' => 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}";
// MessageService::send($userIds, $msg);
$pushData = [
'sender_id' => 0,
'sender_type' => 'system',
'title' => $msg,
'content' => $msg,
'scene' => 1,
'recipient' => $userIds,
'push_method' => 1
];
$msgId = (new MessageNotificationsService())->pushNotice($pushData);
var_dump($msgId);
}
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<int, array{name:string,type:string,condition?:string}>,
* records: array<int, array{
* id:int, instance_id:int, approver_id:int, step_index:int,
* action:?string, status:string, comment:?string,
* approved_at:?string, created_at:?string, updated_at:?string,
* approver_name?:?string,
* step_name:string, step_type:string,
* }>,
* 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,
];
}
/**
* 极简:审批实例的时间轴(只返回每一步的总状态)
*
* @param int $instanceId
* @return array<int, array{index:int,name:string,type:string,status:string}>
*/
public function getSimpleTimeline1(int $instanceId): 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) ?: [];
$currentStep = (int)$instance->current_step;
// 读出所有记录并分组
$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();
$byStep = [];
foreach ($records as $r) {
$idx = (int)$r->step_index;
$byStep[$idx][] = $r;
}
$timeline = [];
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';
if ($hasRecs) {
// 统计是否全部 timeout(且无 approve/reject/pending)
$cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0];
foreach ($recs as $rr) {
if (isset($cnt[$rr->status])) $cnt[$rr->status]++;
}
// 用已有的聚合规则拿到结论
$outcome = $this->getStepOutcome($instanceId, $i, $type); // 'pending'|'approve'|'reject'
if ($outcome === 'reject') {
$status = 'rejected';
} elseif ($outcome === 'approve') {
$status = 'approved';
} else {
// 没有结论,再看是不是纯超时
if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) {
$status = 'timeout';
} else {
$status = 'processing';
}
}
} else {
// 无记录:根据位置与条件判断展示
if ($i < $currentStep) {
$status = $condOk ? 'approved' : 'skipped';
} elseif ($i === $currentStep && $instance->status === 'processing') {
// 理论上当前步应有记录;防御性展示为 processing
$status = 'processing';
} else {
$status = $condOk ? 'pending' : 'skipped';
}
}
$timeline[] = [
'index' => (int)$i,
'name' => $name,
'type' => $type, // any | all
'status' => $status, // approved | rejected | processing | pending | skipped | timeout
];
}
return $timeline;
}
/**
* 极简时间轴(含每步时间 & 申请人信息)
*
* @param int $instanceId
* @param string|null $userNameColumn 申请人姓名列(如 'name' / 'nickname')。不需要就传 null
* @param string $initiatorPlacement 'prepend' | 'attach'
* - 'prepend':在最前插入一条“发起申请”块(推荐)
* - 'attach' :把申请人信息并到第一步的返回项里(不新增块)
*
* 返回:按显示顺序的数组,每项:
* {
* index:int // 步骤索引;若为“发起申请”块则为 -1
* name:string // 步骤名或“发起申请”
* type:string // any|all 或 '-'(发起块)
* status:string // approved|rejected|processing|pending|skipped|timeout|submitted(发起块)
* started_at:?string
* finished_at:?string
* duration_seconds:?int
* // 仅发起块或 attach 时的第一步会包含:
* initiator_id?:int
* initiator_name?:?string
* }
*/
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;
// 申请人
$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)
->orderBy('ar.step_index')
->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc')
->select('ar.*')
->get()
->toArray();
$byStep = [];
foreach ($records as $r) {
$idx = (int)$r->step_index;
$byStep[$idx][] = $r;
}
$timeline = [];
// 可选:先插入“发起申请”块(index = -1)
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;
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) {
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]++;
}
$startedAt = $minCreated;
// 总结果
$outcome = $this->getStepOutcome($instanceId, $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,
];
// 如果需要把申请人信息“并到第一步”而不是独立一块
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<int, array{approver_id:int, approver_name?:?string}>
* }
* }
*/
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;
$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.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('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('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;
}
}