Files
fdesk/app/common/utils/approval/traits/ApprovalEngineTrait.php
T
2026-01-07 17:23:03 +08:00

593 lines
26 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\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;
}
}