2025-12-09 07:33:04 +00:00
|
|
|
|
<?php
|
2026-01-07 17:20:21 +08:00
|
|
|
|
namespace app\common\utils\approval\traits;
|
2025-12-09 07:33:04 +00:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|