This commit is contained in:
mzl
2025-12-09 07:33:04 +00:00
parent 399eaa2b5c
commit 725710abcb
14 changed files with 4213 additions and 1964 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
<?php
namespace app\common\utils\approval\service\traits;
use support\Db;
use support\exception\BusinessException;
trait ApprovalDiscussionTrait
{
/** 是否该实例参与者 */
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;
}
/** 发表讨论 */
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,
];
}
/** 获取讨论列表 */
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);
}
}
@@ -0,0 +1,592 @@
<?php
namespace app\common\utils\approval\service\traits;
use support\Db;
use support\Log;
use support\exception\BusinessException;
trait ApprovalEngineTrait
{
/** 启动审批流程 */
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,
'round' => 1,
'deadline_at' => $deadlineAt,
'created_at' => $now,
'updated_at' => $now,
]);
$instance = (object)[
'id' => $instanceId,
'flow_type' => $flowType,
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'initiator_id' => $initiatorId,
'current_step' => $firstIndex,
'status' => 'processing',
'flow_type' => $flowType,
'biz_type' => $bizType,
'biz_id' => $bizId,
'round' => 1,
];
// 解析首步审批人(支持 role_id 或 roles.name)
$approverIds = $this->resolveApprovers($firstStep, $instance, true);
foreach ($approverIds as $uid) {
$this->insertApprovalRecord($instance, $firstIndex, $uid);
}
$extra = [];
$submitNote = $formData['submit_note'] ?? '';
if (is_string($submitNote) && $submitNote !== '') {
$extra['note'] = $submitNote;
}
$this->sendNotification($approverIds, $instance, 'pending', false, $extra);
// $this->notifyCCs($firstStep, $instance, 'pending');
$this->pushTodoChanged($approverIds);
return ['instance_id' => $instanceId];
});
}
/** 执行审批(幂等友好) */
public function approve(int $instanceId, int $approverId, string $action, string $comment = '')
{
if (!in_array($action, ['approve','reject'], true)) {
throw new BusinessException("无效的审批操作");
}
return Db::transaction(function () use ($instanceId, $approverId, $action, $comment) {
$instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first();
if (!$instance) throw new BusinessException("审批实例未找到");
if ($instance->status !== 'processing') {
return ['status' => 'noop', 'msg' => '实例未处理'];
}
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
if (!$flow) throw new BusinessException("审批流程定义未找到");
$steps = json_decode($flow->steps, true);
$idx = (int)$instance->current_step;
$round = (int)$instance->round;
$step = $steps[$idx] ?? null;
if (!$step) throw new BusinessException("当前步骤配置错误");
// 找到该用户在本步的 pending 记录(加锁)
$pendingRecord = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('round', $round)
->where('approver_id', $approverId)
->where('step_index', $idx)
->where('status', 'pending')
->lockForUpdate()
->first();
if (!$pendingRecord) {
// 已处理过?
$anyRecord = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('round', $round)
->where('approver_id', $approverId)
->where('step_index', $idx)
->first();
if ($anyRecord && $anyRecord->status !== 'pending') {
return ['status' => 'noop', 'msg' => '已处理'];
}
// 步骤已推进
if ((int)$instance->current_step !== $idx) {
return ['status' => 'noop', 'msg' => '非当前审批'];
}
return ['status' => 'noop', 'msg' => '无待处理记录'];
}
$now = date('Y-m-d H:i:s');
// 1) 更新当前人的审批记录
Db::table('approval_records')->where('id', $pendingRecord->id)->update([
'action' => $action,
'status' => ($action === 'approve') ? 'approved' : 'rejected',
'comment' => $comment,
'approved_at' => $now,
'updated_at' => $now,
]);
// 异步审批回调 9.23 add
$approvalcallback = [
'biz_id' => $instance->biz_id,
'flow_type' => $instance->flow_type,
'instance_id' => $instance->id,
'initiator_id' => $instance->initiator_id,
'status' => $action
];
\Webman\RedisQueue\Redis::send('approval-callback', $approvalcallback);
// 2) 聚合本步结论(any / all)
$typeRaw = strtolower($step['type'] ?? 'all');
$type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all';
$outcome = $this->getStepOutcome($instanceId, $round, $idx, $type); // 'pending' | 'approve' | 'reject'
if ($outcome === 'pending') {
$this->pushTodoChanged([$approverId]);
return ['status' => 'step_pending', 'msg' => '审批待定'];
}
$otherPendingUids = $this->getPendingApproverIds($instanceId, $round, $idx);
// 3) 有结论:关闭本步其余 pending
$this->closeOtherPending($instanceId, $round, $idx, [$approverId]);
// 这些人 pending→skipped,待办数减少
$this->pushTodoChanged($otherPendingUids);
// 4) 拒绝 => 结束
if ($outcome === 'reject') {
Db::table('approval_instances')->where('id', $instanceId)->update([
'status' => 'rejected',
'finished_at' => $now,
'updated_at' => $now,
]);
$this->onFlowFinalized($instance, 'rejected');
$this->sendNotification([$instance->initiator_id], $instance, 'reject', false);
$this->pushTodoChanged([$approverId]);
return ['status' => 'completed', 'msg' => '拒绝'];
}
// 5) 通过 => 进入下一步或结束
$formData = json_decode($instance->form_data, true) ?: [];
$nextIndex = $this->getNextStepIndex($steps, $formData, $idx);
if ($nextIndex === null) {
$finalStatus = $this->normalizeFinalStatus((string)$instance->flow_type, 'approved');
$notifyAction = ($finalStatus === 'voided') ? 'voided' : 'approve';
Db::table('approval_instances')->where('id', $instanceId)->update([
'status' => $finalStatus,
'finished_at' => $now,
'updated_at' => $now,
]);
$this->onFlowFinalized($instance, $finalStatus);
// 新增:在流程最终完成时才发本步骤的抄送
if (!empty($step['cc'])) {
$this->notifyCCs($step, $instance, $notifyAction);
}
$this->sendNotification([$instance->initiator_id], $instance, $notifyAction, false);
$this->pushTodoChanged([$approverId]);
return ['status' => 'completed', 'msg' => ($finalStatus === 'voided' ? '已作废' : '同意')];
}
// 乐观推进 current_step
$affected = Db::table('approval_instances')
->where('id', $instanceId)
->where('current_step', $idx)
->where('status', 'processing')
->update(['current_step' => $nextIndex, 'updated_at' => $now]);
if (!$affected) {
return ['status' => 'noop', 'msg' => '并发进展'];
}
// 生成下一步待办 + 通知
$nextStep = $steps[$nextIndex];
$nextApprovers = $this->resolveApprovers($nextStep, $instance, true);
foreach ($nextApprovers as $uid) {
$this->insertApprovalRecord($instance, $nextIndex, $uid);
}
$this->sendNotification($nextApprovers, $instance, 'pending', false);
// $this->notifyCCs($nextStep, $instance, 'pending');
$this->pushTodoChanged(array_merge([$approverId], $nextApprovers));
return ['status' => 'next_step', 'msg' => '进行下一步:'.$nextStep['name'] ?? ('Step '.$nextIndex)];
});
}
/** 撤回(仅发起人在无已处理记录前可撤回) */
public function revoke(int $instanceId, int $initiatorId, string $reason = '')
{
return Db::transaction(function () use ($instanceId, $initiatorId, $reason) {
$instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first();
if (!$instance) throw new BusinessException('审批实例未找到');
if ((int)$instance->initiator_id !== (int)$initiatorId) throw new BusinessException('无权撤回');
if ($instance->status !== 'processing') throw new BusinessException('流程已结束或不可撤回');
$handled = Db::table('approval_records')
->where('instance_id', $instanceId)
->whereIn('status', ['approved','rejected','timeout','skipped'])
->count() > 0;
if ($handled) throw new BusinessException('当前进度不可撤回');
$now = date('Y-m-d H:i:s');
Db::table('approval_instances')->where('id', $instanceId)->update([
'status' => 'revoked',
'finished_at' => $now,
'updated_at' => $now,
]);
$this->onFlowFinalized($instance, 'revoked');
$pendingUids = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('status', 'pending')
->pluck('approver_id')->toArray();
Db::table('approval_records')
->where('instance_id', $instanceId)
->where('status', 'pending')
->update(['status' => 'skipped', 'approved_at' => $now, 'updated_at' => $now]);
$this->sendNotification([$initiatorId], $instance, 'revoked', false);
$this->pushTodoChanged($pendingUids);
return ['status' => 'completed', 'result' => 'revoked'];
});
}
/** 定时任务:处理超时(实例级) */
public function handleTimeouts()
{
$now = date('Y-m-d H:i:s');
$expired = Db::table('approval_instances')
->where('status', 'processing')
->whereNotNull('deadline_at')
->where('deadline_at', '<=', $now)
->get();
foreach ($expired as $instance) {
Db::transaction(function () use ($instance, $now) {
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
if (!$flow) return;
$final = ($flow->timeout_action === 'auto_approve') ? 'approved' : 'rejected';
$final = $this->normalizeFinalStatus((string)$instance->flow_type, $final);
$finalAction = ($final === 'voided') ? 'voided' : (($final === 'approved') ? 'approve' : 'reject');
Db::table('approval_instances')->where('id', $instance->id)->update([
'status' => $final,
'finished_at' => $now,
'updated_at' => $now,
]);
$this->onFlowFinalized((object)$instance, $final);
$pendingUids = Db::table('approval_records')
->where('instance_id', $instance->id)
->where('round', (int)$instance->round)
->where('step_index', $instance->current_step)
->where('status', 'pending')
->pluck('approver_id')->toArray();
Db::table('approval_records')
->where('instance_id', $instance->id)
->where('round', (int)$instance->round)
->where('step_index', $instance->current_step)
->where('status', 'pending')
->update(['status' => 'timeout', 'approved_at' => $now, 'updated_at' => $now]);
$this->notifyInitiator($instance, $finalAction);
$this->pushTodoChanged($pendingUids);
});
}
}
/** 重提交(仅对 status=rejected 的实例;支持申诉说明) */
public function resubmit(int $instanceId, int $initiatorId, array $patchData = [], string $appealText = ''): array
{
return Db::transaction(function () use ($instanceId, $initiatorId, $patchData, $appealText) {
$instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first();
if (!$instance) throw new BusinessException('审批实例未找到');
if ((int)$instance->initiator_id !== (int)$initiatorId) throw new BusinessException('仅发起人可重提');
if ($instance->status !== 'rejected') throw new BusinessException('仅被驳回的实例可重提');
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
if (!$flow) throw new BusinessException('审批流程定义未找到');
$steps = json_decode($flow->steps, true) ?: [];
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
// 合并改动 & 打上重提标记
$formData = array_merge($formData, $patchData);
$formData['resubmitted'] = 1;
$appealText = trim((string)$appealText);
if ($appealText !== '') {
$formData['appeal_last'] = $appealText;
}
// 计算重提后首个命中的步骤
$firstIndex = $this->getNextStepIndex($steps, $formData, -1);
if ($firstIndex === null) throw new BusinessException('重提后未找到可执行的步骤');
$now = date('Y-m-d H:i:s');
$deadlineAt = null;
if (($flow->timeout_hours ?? 0) > 0) {
$deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600);
}
$oldPendingUids = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('status', 'pending')
->pluck('approver_id')->toArray();
// 清理可能遗留的 pending
Db::table('approval_records')
->where('instance_id', $instanceId)
->where('status', 'pending')
->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]);
// 轮次 +1
$newRound = ((int)$instance->round) + 1;
// 重新开启实例
Db::table('approval_instances')->where('id', $instanceId)->update([
'status' => 'processing',
'current_step' => $firstIndex,
'round' => $newRound,
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'finished_at' => null,
'deadline_at' => $deadlineAt,
'updated_at' => $now,
]);
// 创建待办 + 通知
$firstStep = $steps[$firstIndex];
$instanceLite = (object)[
'id' => (int)$instanceId,
'flow_type' => (string)$instance->flow_type,
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'initiator_id' => (int)$instance->initiator_id,
'biz_type' => (string)$instance->biz_type,
'biz_id' => (string)$instance->biz_id,
'round' => $newRound,
];
$approvers = $this->resolveApprovers($firstStep, $instanceLite, true);
if (empty($approvers)) throw new BusinessException('重提后首步未解析到审批人');
foreach ($approvers as $uid) {
$this->insertApprovalRecord($instanceLite, $firstIndex, (int)$uid);
}
$extra = $appealText !== '' ? ['appeal' => $appealText] : [];
$this->sendNotification($approvers, $instanceLite, 'pending', false, $extra);
$this->notifyCCs($firstStep, $instanceLite, 'pending');
$this->pushTodoChanged(array_merge($oldPendingUids, $approvers));
return [
'status' => 'resubmitted',
'instance_id' => (int)$instanceId,
'current_step' => $firstIndex,
'step_name' => $firstStep['name'] ?? ('Step '.$firstIndex),
];
});
}
/** 新增:发起“作废审批” */
public function startNullify(
string $voidFlowType,
string $bizType,
string $bizId,
array $formData,
int $initiatorId
): array {
return Db::transaction(function () use ($voidFlowType, $bizType, $bizId, $formData, $initiatorId) {
// 1) 流程定义校验
$flow = Db::table('approval_flows')->where('type', $voidFlowType)->where('is_active', 1)->first();
if (!$flow) throw new BusinessException("作废流程未找到或已停用: {$voidFlowType}");
$steps = json_decode($flow->steps, true);
if (!is_array($steps) || empty($steps)) throw new BusinessException("作废流程步骤配置错误");
// 2) 必须存在“已通过”的最新有效实例(非作废流程)
$latestApproved = Db::table('approval_instances')
->where('biz_type', $bizType)
->where('biz_id', $bizId)
->where('flow_type', '!=', $voidFlowType)
->where('status', 'approved')
->orderByDesc('id')
->first();
if (!$latestApproved) {
throw new BusinessException('仅已通过的单据可发起作废');
}
// 3) 不允许并行的作废流程
$voidProcessing = Db::table('approval_instances')
->where('flow_type', $voidFlowType)
->where('biz_id', $bizId)
->where('status', 'processing')
->exists();
if ($voidProcessing) {
throw new BusinessException('已存在进行中的作废审批');
}
// A. 基于历史“作废流程已通过”的实例拦截
$voidApproved = Db::table('approval_instances')
->where('flow_type', $voidFlowType)
->where('biz_id', $bizId)
->whereIn('status', ['approved','voided'])
->exists();
if ($voidApproved) {
throw new BusinessException('该单据已作废,无需重复作废');
}
// B.(可选)直接查业务表状态拦截(若你用 approval_status=4 表示已作废)
/*
$orderVoided = \app\common\model\StockOrders::where('id', $bizId)
->where('approval_status', 4)
->exists();
if ($orderVoided) {
throw new BusinessException('该单据已作废,无需重复作废');
}
*/
// 4) 计算首步
// 注:作废流程可以有独立条件;若不需要条件,直接第一步
$formData = array_merge([
'_op' => 'nullify',
'_nullify_reason' => $formData['nullify_reason'] ?? ($formData['reason'] ?? ''),
'_nullify_of_instance_id' => (int)$latestApproved->id,
], $formData);
$firstIndex = $this->getNextStepIndex($steps, $formData, -1);
if ($firstIndex === null) throw new BusinessException("作废流程未命中任何审批步骤");
$firstStep = $steps[$firstIndex];
$now = date('Y-m-d H:i:s');
$deadlineAt = null;
if (($flow->timeout_hours ?? 0) > 0) {
$deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600);
}
// 5) 新建作废实例(round 固定从 1 开始)
$instanceId = Db::table('approval_instances')->insertGetId([
'flow_type' => $voidFlowType,
'biz_type' => $bizType,
'biz_id' => $bizId,
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'initiator_id' => $initiatorId,
'status' => 'processing',
'current_step' => $firstIndex,
'round' => 1, //
'deadline_at' => $deadlineAt,
'created_at' => $now,
'updated_at' => $now,
]);
$instance = (object)[
'id' => $instanceId,
'flow_type' => $voidFlowType,
'biz_type' => $bizType,
'biz_id' => $bizId,
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'initiator_id' => $initiatorId,
'current_step' => $firstIndex,
'status' => 'processing',
'round' => 1, //
];
// 6) 生成首步待办 + 通知(带上作废原因)
$approverIds = $this->resolveApprovers($firstStep, $instance, true);
foreach ($approverIds as $uid) {
$this->insertApprovalRecord($instance, $firstIndex, $uid);
}
$extra = [];
if (!empty($formData['_nullify_reason'])) {
$extra['note'] = '【作废原因】'.$formData['_nullify_reason'];
}
$this->sendNotification($approverIds, $instance, 'pending', false, $extra);
$this->notifyCCs($firstStep, $instance, 'pending');
$this->pushTodoChanged($approverIds);
return ['instance_id' => $instanceId];
});
}
// 流程完结后的业务回调(approved|rejected|revoked|timeout?)
private function onFlowFinalized(object $instance, string $final): void
{
try {
switch ($instance->flow_type) {
case 'stock_orders': {
$order = \app\common\model\StockOrders::find($instance->biz_id);
if (!$order) {
throw new BusinessException('订单不存在');
}
if ($final === 'approved') {
$order->approval_status = 2;
$order->save();
} elseif ($final === 'rejected') {
$order->approval_status = 3;
$order->save();
} elseif ($final === 'revoked') {
$order->approval_status = 0;
$order->save();
}
break;
}
case 'stock_orders_nullify':
if ($final === 'approved') {
$final = 'voided';
Db::table('approval_instances')->where('id', $instance->id)->update([
'status' => 'voided',
]);
}
if (in_array($final, ['voided','approved'], true)) {
$order = \app\common\model\StockOrders::find($instance->biz_id);
if ($order && $order->approval_status !== 4) {
$order->approval_status = 4;
$order->save();
}
}
break;
default:
// 非作废流程,无需处理
break;
}
} catch (\Throwable $e) {
Log::error('onFlowFinalized error: '.$e->getMessage());
}
}
// 是否作废流程
private function isNullifyFlow(string $flowType): bool
{
$t = strtolower(trim($flowType));
return str_ends_with($t, '_nullify') || in_array($t, [
// 所有“作废”类流程的类型
'stock_orders_nullify',
], true);
}
// 归一化最终状态
private function normalizeFinalStatus(string $flowType, string $final): string
{
if ($this->isNullifyFlow($flowType) && $final === 'approved') {
return 'voided';
}
return $final;
}
}
@@ -0,0 +1,155 @@
<?php
namespace app\common\utils\approval\service\traits;
use support\Db;
use support\Log;
trait ApprovalNotificationTrait
{
/** 发送通知 */
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);
}
/** 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());
}
}
/** WebSocket:把一组用户的“待办数量”推送给前端(统一口径) */
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());
}
}
}
@@ -0,0 +1,475 @@
<?php
namespace app\common\utils\approval\service\traits;
use support\Db;
use support\exception\BusinessException;
trait ApprovalQueryTrait
{
/** 流程图数据 */
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 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获取“最新进展”的极简状态 */
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;
}
/** 登录/首屏初始化:获取当前用户的待办总数 */
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);
}
}
@@ -0,0 +1,406 @@
<?php
namespace app\common\utils\approval\service\traits;
use support\Db;
use support\exception\BusinessException;
trait ApprovalResolverTrait
{
/** 解析审批人(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}"),
};
}
/** 获取下一个有效步骤索引 */
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,
]);
}
/** 取“某实例、某轮次、某步骤”里当前 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)));
}
/** 从角色取用户ID */
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;
}
/** 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;
}
}
@@ -0,0 +1,130 @@
<?php
namespace app\common\utils\approval\service\traits;
use support\Db;
use support\exception\BusinessException;
trait ApprovalTransferTrait
{
/** 查看某个实例的转交流水(按时间升序) */
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);
}
/** 转交当前待办给他人 */
public function transferApproval(
int $instanceId,
int $stepIndex,
int $fromUserId,
int $toUserId,
string $reason = '',
?string $userNameColumn = null
): 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];
});
}
}