Files
fdesk/app/common/utils/approval/service/ApprovalFlowService.php
T

1953 lines
83 KiB
PHP
Raw Normal View History

2025-09-04 10:26:28 +08:00
<?php
2025-09-08 11:34:19 +08:00
namespace app\common\utils\approval\service;
2025-09-04 10:26:28 +08:00
use support\exception\BusinessException;
use support\Db;
2025-10-11 08:41:11 +00:00
use support\Log;
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
class ApprovalFlowService
2025-09-04 10:26:28 +08:00
{
2025-09-09 13:42:00 +08:00
/** 启动审批流程 */
2025-09-04 10:26:28 +08:00
public function start(string $flowType, string $bizType, string $bizId, array $formData, int $initiatorId): array
{
2025-09-09 13:42:00 +08:00
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);
}
2025-09-08 20:00:12 +08:00
2025-09-09 13:42:00 +08:00
$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,
2025-09-24 11:18:59 +08:00
'round' => 1,
2025-09-09 13:42:00 +08:00
'deadline_at' => $deadlineAt,
'created_at' => $now,
'updated_at' => $now,
2025-09-04 10:26:28 +08:00
]);
2025-09-09 13:42:00 +08:00
$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',
2025-09-15 16:38:00 +08:00
'flow_type' => $flowType,
'biz_type' => $bizType,
'biz_id' => $bizId,
2025-09-24 11:18:59 +08:00
'round' => 1,
2025-09-09 13:42:00 +08:00
];
// 解析首步审批人(支持 role_id 或 roles.name)
$approverIds = $this->resolveApprovers($firstStep, $instance, true);
foreach ($approverIds as $uid) {
2025-09-24 11:18:59 +08:00
$this->insertApprovalRecord($instance, $firstIndex, $uid);
2025-09-09 13:42:00 +08:00
}
2025-09-04 10:26:28 +08:00
2025-09-19 07:43:48 +00:00
$extra = [];
$submitNote = $formData['submit_note'] ?? '';
if (is_string($submitNote) && $submitNote !== '') {
$extra['note'] = $submitNote;
}
$this->sendNotification($approverIds, $instance, 'pending', false, $extra);
2025-11-03 18:16:51 +08:00
// $this->notifyCCs($firstStep, $instance, 'pending');
2025-09-04 10:26:28 +08:00
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged($approverIds);
2025-09-09 13:42:00 +08:00
return ['instance_id' => $instanceId];
});
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
/** 执行审批(幂等友好) */
2025-09-04 10:26:28 +08:00
public function approve(int $instanceId, int $approverId, string $action, string $comment = '')
{
2025-09-09 13:42:00 +08:00
if (!in_array($action, ['approve','reject'], true)) {
2025-09-08 20:00:12 +08:00
throw new BusinessException("无效的审批操作");
}
2025-09-09 13:42:00 +08:00
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' => '实例未处理'];
}
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
$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;
2025-09-24 11:18:59 +08:00
$round = (int)$instance->round;
2025-09-09 13:42:00 +08:00
$step = $steps[$idx] ?? null;
if (!$step) throw new BusinessException("当前步骤配置错误");
// 找到该用户在本步的 pending 记录(加锁)
$pendingRecord = Db::table('approval_records')
->where('instance_id', $instanceId)
2025-09-24 11:18:59 +08:00
->where('round', $round)
2025-09-09 13:42:00 +08:00
->where('approver_id', $approverId)
->where('step_index', $idx)
->where('status', 'pending')
->lockForUpdate()
->first();
if (!$pendingRecord) {
// 已处理过?
$anyRecord = Db::table('approval_records')
->where('instance_id', $instanceId)
2025-09-24 11:18:59 +08:00
->where('round', $round)
2025-09-09 13:42:00 +08:00
->where('approver_id', $approverId)
->where('step_index', $idx)
->first();
if ($anyRecord && $anyRecord->status !== 'pending') {
return ['status' => 'noop', 'msg' => '已处理'];
}
// 步骤已推进
if ((int)$instance->current_step !== $idx) {
2025-09-10 13:39:06 +08:00
return ['status' => 'noop', 'msg' => '非当前审批'];
2025-09-09 13:42:00 +08:00
}
return ['status' => 'noop', 'msg' => '无待处理记录'];
}
2025-09-08 20:00:12 +08:00
2025-09-09 13:42:00 +08:00
$now = date('Y-m-d H:i:s');
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
// 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,
]);
2025-09-04 10:26:28 +08:00
2025-09-24 11:18:59 +08:00
// 异步审批回调 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);
2025-09-11 16:52:10 +08:00
2025-09-09 13:42:00 +08:00
// 2) 聚合本步结论(any / all)
$typeRaw = strtolower($step['type'] ?? 'all');
$type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all';
2025-09-24 11:18:59 +08:00
$outcome = $this->getStepOutcome($instanceId, $round, $idx, $type); // 'pending' | 'approve' | 'reject'
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
if ($outcome === 'pending') {
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged([$approverId]);
2025-09-10 13:39:06 +08:00
return ['status' => 'step_pending', 'msg' => '审批待定'];
2025-09-09 13:42:00 +08:00
}
2025-09-08 20:00:12 +08:00
2025-10-12 06:22:29 +00:00
$otherPendingUids = $this->getPendingApproverIds($instanceId, $round, $idx);
2025-09-09 13:42:00 +08:00
// 3) 有结论:关闭本步其余 pending
$this->closeOtherPending($instanceId, $round, $idx, [$approverId]);
2025-09-04 10:26:28 +08:00
2025-10-12 06:22:29 +00:00
// 这些人 pending→skipped,待办数减少
$this->pushTodoChanged($otherPendingUids);
2025-09-09 13:42:00 +08:00
// 4) 拒绝 => 结束
if ($outcome === 'reject') {
Db::table('approval_instances')->where('id', $instanceId)->update([
'status' => 'rejected',
'finished_at' => $now,
'updated_at' => $now,
]);
2025-10-10 11:09:37 +00:00
$this->onFlowFinalized($instance, 'rejected');
2025-09-09 13:42:00 +08:00
$this->sendNotification([$instance->initiator_id], $instance, 'reject', false);
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged([$approverId]);
2025-09-24 11:18:59 +08:00
return ['status' => 'completed', 'msg' => '拒绝'];
2025-09-09 13:42:00 +08:00
}
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
// 5) 通过 => 进入下一步或结束
$formData = json_decode($instance->form_data, true) ?: [];
$nextIndex = $this->getNextStepIndex($steps, $formData, $idx);
if ($nextIndex === null) {
2025-10-11 08:41:11 +00:00
$finalStatus = $this->normalizeFinalStatus((string)$instance->flow_type, 'approved');
$notifyAction = ($finalStatus === 'voided') ? 'voided' : 'approve';
2025-09-09 13:42:00 +08:00
Db::table('approval_instances')->where('id', $instanceId)->update([
2025-10-11 08:41:11 +00:00
'status' => $finalStatus,
2025-09-09 13:42:00 +08:00
'finished_at' => $now,
'updated_at' => $now,
]);
2025-10-11 08:41:11 +00:00
$this->onFlowFinalized($instance, $finalStatus);
2025-11-03 18:16:51 +08:00
// 新增:在流程最终完成时才发本步骤的抄送
if (!empty($step['cc'])) {
$this->notifyCCs($step, $instance, $notifyAction);
}
2025-10-11 08:41:11 +00:00
$this->sendNotification([$instance->initiator_id], $instance, $notifyAction, false);
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged([$approverId]);
2025-10-11 08:41:11 +00:00
return ['status' => 'completed', 'msg' => ($finalStatus === 'voided' ? '已作废' : '同意')];
2025-09-09 13:42:00 +08:00
}
// 乐观推进 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' => '并发进展'];
}
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
// 生成下一步待办 + 通知
$nextStep = $steps[$nextIndex];
$nextApprovers = $this->resolveApprovers($nextStep, $instance, true);
foreach ($nextApprovers as $uid) {
2025-09-24 11:18:59 +08:00
$this->insertApprovalRecord($instance, $nextIndex, $uid);
2025-09-09 13:42:00 +08:00
}
$this->sendNotification($nextApprovers, $instance, 'pending', false);
2025-11-03 18:16:51 +08:00
// $this->notifyCCs($nextStep, $instance, 'pending');
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged(array_merge([$approverId], $nextApprovers));
2025-09-09 13:42:00 +08:00
return ['status' => 'next_step', 'msg' => '进行下一步:'.$nextStep['name'] ?? ('Step '.$nextIndex)];
});
}
2025-09-04 10:26:28 +08:00
2025-10-11 08:41:11 +00:00
// 流程完结后的业务回调(approved|rejected|revoked|timeout?)
private function onFlowFinalized(object $instance, string $final): void
2025-10-10 11:09:37 +00:00
{
try {
switch ($instance->flow_type) {
case 'stock_orders': {
$order = \app\common\model\StockOrders::find($instance->biz_id);
2025-10-11 08:41:11 +00:00
if (!$order) {
throw new BusinessException('订单不存在');
}
2025-10-10 11:09:37 +00:00
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') {
2025-10-11 08:41:11 +00:00
$final = 'voided';
Db::table('approval_instances')->where('id', $instance->id)->update([
'status' => 'voided',
]);
}
if (in_array($final, ['voided','approved'], true)) {
2025-10-10 11:09:37 +00:00
$order = \app\common\model\StockOrders::find($instance->biz_id);
if ($order && $order->approval_status !== 4) {
2025-10-11 08:41:11 +00:00
$order->approval_status = 4;
2025-10-10 11:09:37 +00:00
$order->save();
}
}
break;
default:
// 非作废流程,无需处理
break;
}
} catch (\Throwable $e) {
2025-10-11 08:41:11 +00:00
Log::error('onFlowFinalized error: '.$e->getMessage());
2025-10-10 11:09:37 +00:00
}
}
2025-09-09 13:42:00 +08:00
/** 撤回(仅发起人在无已处理记录前可撤回) */
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');
2025-09-04 10:26:28 +08:00
Db::table('approval_instances')->where('id', $instanceId)->update([
2025-09-09 13:42:00 +08:00
'status' => 'revoked',
'finished_at' => $now,
'updated_at' => $now,
2025-09-04 10:26:28 +08:00
]);
2025-10-10 11:09:37 +00:00
$this->onFlowFinalized($instance, 'revoked');
2025-10-12 06:22:29 +00:00
$pendingUids = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('status', 'pending')
->pluck('approver_id')->toArray();
2025-09-09 13:42:00 +08:00
Db::table('approval_records')
->where('instance_id', $instanceId)
->where('status', 'pending')
->update(['status' => 'skipped', 'approved_at' => $now, 'updated_at' => $now]);
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
$this->sendNotification([$initiatorId], $instance, 'revoked', false);
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged($pendingUids);
2025-09-09 13:42:00 +08:00
return ['status' => 'completed', 'result' => 'revoked'];
});
}
/** 流程图数据(把 skipped/timeout 视作已完结的中性状态) */
public function getFlowDiagramData(int $instanceId): array
{
$instance = Db::table('approval_instances')->find($instanceId);
if (!$instance) return [];
2025-09-08 20:00:12 +08:00
2025-09-09 13:42:00 +08:00
$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)
2025-09-24 11:18:59 +08:00
->where('round', (int)$instance->round)
2025-09-09 13:42:00 +08:00
->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,
];
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
return $data;
}
/** 定时任务:处理超时(实例级) */
public function handleTimeouts()
{
$now = date('Y-m-d H:i:s');
$expired = Db::table('approval_instances')
->where('status', 'processing')
->whereNotNull('deadline_at')
->where('deadline_at', '<=', $now)
->get();
foreach ($expired as $instance) {
Db::transaction(function () use ($instance, $now) {
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
if (!$flow) return;
2025-10-11 08:41:11 +00:00
$final = ($flow->timeout_action === 'auto_approve') ? 'approved' : 'rejected';
$final = $this->normalizeFinalStatus((string)$instance->flow_type, $final);
$finalAction = ($final === 'voided') ? 'voided' : (($final === 'approved') ? 'approve' : 'reject');
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
Db::table('approval_instances')->where('id', $instance->id)->update([
'status' => $final,
'finished_at' => $now,
'updated_at' => $now,
]);
2025-09-04 10:26:28 +08:00
2025-10-10 11:09:37 +00:00
$this->onFlowFinalized((object)$instance, $final);
2025-10-12 06:22:29 +00:00
$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();
2025-09-09 13:42:00 +08:00
Db::table('approval_records')
->where('instance_id', $instance->id)
2025-09-24 11:18:59 +08:00
->where('round', (int)$instance->round)
2025-09-09 13:42:00 +08:00
->where('step_index', $instance->current_step)
->where('status', 'pending')
->update(['status' => 'timeout', 'approved_at' => $now, 'updated_at' => $now]);
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
$this->notifyInitiator($instance, $finalAction);
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged($pendingUids);
2025-09-09 13:42:00 +08:00
});
}
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
// ---------- 内部工具 ----------
/** 解析审批人(role/user/initiator_leader/field_user/dept_head) */
private function resolveApprovers(array $step, object $instance, bool $avoidSelf = true): array
2025-09-04 10:26:28 +08:00
{
$resolved = [];
2025-09-09 13:42:00 +08:00
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
$confs = $step['approvers'] ?? [];
foreach ($confs as $cfg) {
$typ = $cfg['type'] ?? '';
$val = $cfg['value'] ?? null;
$ids = [];
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
switch ($typ) {
2025-09-04 10:26:28 +08:00
case 'role':
2025-09-09 13:42:00 +08:00
$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);
2025-09-04 10:26:28 +08:00
break;
case 'user':
2025-09-09 13:42:00 +08:00
$ids = [(int)$val];
2025-09-04 10:26:28 +08:00
break;
case 'initiator_leader':
2025-09-09 13:42:00 +08:00
$leaderId = $this->getLeaderIdByUserId((int)$instance->initiator_id);
if ($leaderId) $ids = [(int)$leaderId];
2025-09-04 10:26:28 +08:00
break;
case 'field_user':
2025-09-09 13:42:00 +08:00
if (isset($formData[$val])) $ids = [(int)$formData[$val]];
2025-09-04 10:26:28 +08:00
break;
case 'dept_head':
2025-09-09 13:42:00 +08:00
$head = $this->getDeptHeadId((int)$val);
if ($head) $ids = [(int)$head];
2025-09-04 10:26:28 +08:00
break;
2025-10-10 11:09:37 +00:00
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;
}
2025-09-04 10:26:28 +08:00
default:
2025-09-09 13:42:00 +08:00
throw new BusinessException("不支持的审批人类型: {$typ}");
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
$resolved = array_merge($resolved, $ids);
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
$resolved = array_unique(array_filter(array_map('intval', $resolved)));
if ($avoidSelf) {
2025-09-10 13:39:06 +08:00
// $resolved = array_values(array_filter($resolved, fn($id) => $id !== (int)$instance->initiator_id));
$resolved = array_values($resolved);
2025-09-09 13:42:00 +08:00
}
if (empty($resolved)) {
2025-09-10 13:39:06 +08:00
throw new BusinessException("未解析到任何审批人");
2025-09-09 13:42:00 +08:00
}
return $resolved;
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
/** 通知抄送人 */
private function notifyCCs(array $step, object $instance, string $action = 'pending'): void
2025-09-04 10:26:28 +08:00
{
$ccConfig = $step['cc'] ?? [];
2025-09-09 13:42:00 +08:00
if (empty($ccConfig)) return;
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
$uids = [];
2025-09-04 10:26:28 +08:00
foreach ($ccConfig as $cc) {
2025-09-09 13:42:00 +08:00
$typ = $cc['type'] ?? '';
$val = $cc['value'] ?? null;
switch ($typ) {
2025-09-04 10:26:28 +08:00
case 'role':
2025-09-09 13:42:00 +08:00
$uids = array_merge($uids, $this->getUserIdsByRole($val, null, true, true));
2025-09-04 10:26:28 +08:00
break;
2025-09-09 13:42:00 +08:00
case 'user': $uids[] = (int)$val; break;
2025-09-04 10:26:28 +08:00
case 'initiator_leader':
2025-09-09 13:42:00 +08:00
$leaderId = $this->getLeaderIdByUserId((int)$instance->initiator_id);
if ($leaderId) $uids[] = (int)$leaderId;
2025-09-04 10:26:28 +08:00
break;
case 'field_user':
2025-09-09 13:42:00 +08:00
if (isset($formData[$val])) $uids[] = (int)$formData[$val];
2025-09-04 10:26:28 +08:00
break;
case 'dept_head':
2025-09-09 13:42:00 +08:00
$head = $this->getDeptHeadId((int)$val);
if ($head) $uids[] = (int)$head;
2025-09-04 10:26:28 +08:00
break;
2025-10-11 08:41:11 +00:00
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;
}
2025-09-09 13:42:00 +08:00
default: break;
2025-09-04 10:26:28 +08:00
}
}
2025-09-09 13:42:00 +08:00
$uids = array_unique(array_filter(array_map('intval', $uids)));
if (empty($uids)) return;
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
$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,
]);
2025-09-08 20:00:12 +08:00
}
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
$this->sendNotification($uids, $instance, $action, true);
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
/** 条件求值(简单表达式) */
2025-09-04 10:26:28 +08:00
private function evaluateCondition(?string $condition, array $formData): bool
{
if (!$condition) return true;
$pattern = '/^(\w+)\s*(==|!=|>|<|>=|<=)\s*(.+)$/';
2025-09-09 13:42:00 +08:00
if (!preg_match($pattern, $condition, $m)) {
2025-09-04 10:26:28 +08:00
throw new BusinessException("条件格式错误: {$condition}");
}
2025-09-09 13:42:00 +08:00
$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}"),
};
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
/** 获取下一个有效步骤索引(考虑 condition) */
2025-09-08 20:00:12 +08:00
private function getNextStepIndex(array $steps, array $formData, int $currentStepIndex): ?int
2025-09-04 10:26:28 +08:00
{
2025-09-08 20:00:12 +08:00
for ($i = $currentStepIndex + 1; $i < count($steps); $i++) {
2025-09-09 13:42:00 +08:00
$s = $steps[$i];
if (!isset($s['condition']) || $this->evaluateCondition($s['condition'], $formData)) {
2025-09-08 20:00:12 +08:00
return $i;
2025-09-04 10:26:28 +08:00
}
}
return null;
}
/**
2025-09-09 13:42:00 +08:00
* 聚合本步骤结论(按类型)
2025-09-04 10:26:28 +08:00
*/
2025-09-24 11:18:59 +08:00
private function getStepOutcome(int $instanceId, int $round, int $stepIndex, string $type): string
2025-09-04 10:26:28 +08:00
{
2025-09-08 20:00:12 +08:00
$records = Db::table('approval_records')
2025-09-04 10:26:28 +08:00
->where('instance_id', $instanceId)
2025-10-12 06:22:29 +00:00
->where('round', $round) //
2025-09-08 20:00:12 +08:00
->where('step_index', $stepIndex)
->get();
2025-09-04 10:26:28 +08:00
2025-10-11 08:41:11 +00:00
$pending = $approved = $rejected = $timeout = $skipped = 0;
2025-09-09 13:42:00 +08:00
foreach ($records as $r) {
2025-10-11 08:41:11 +00:00
switch ($r->status) {
case 'pending': $pending++; break;
case 'approved': $approved++; break;
case 'rejected': $rejected++; break;
case 'timeout': $timeout++; break;
case 'skipped': $skipped++; break;
}
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
$type = strtolower($type);
if ($type === 'any') {
if ($rejected > 0) return 'reject';
if ($approved > 0) return 'approve';
2025-10-11 08:41:11 +00:00
// 全为 skipped/timeout 且无 pending/rejected ⇒ 通过
if ($pending === 0 && $rejected === 0 && $approved === 0) return 'approve';
2025-09-09 13:42:00 +08:00
return 'pending';
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
if ($rejected > 0) return 'reject';
if ($pending > 0) return 'pending';
2025-10-11 08:41:11 +00:00
return 'approve';
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
/** 关闭本步其余待办为 skipped */
2025-09-24 11:18:59 +08:00
private function closeOtherPending(int $instanceId, int $round, int $stepIndex, array $keepApproverIds = []): void
2025-09-04 10:26:28 +08:00
{
2025-09-09 13:42:00 +08:00
$now = date('Y-m-d H:i:s');
$q = Db::table('approval_records')
->where('instance_id', $instanceId)
2025-10-12 06:22:29 +00:00
->where('round', $round) //
2025-09-09 13:42:00 +08:00
->where('step_index', $stepIndex)
->where('status', 'pending');
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
if (!empty($keepApproverIds)) $q->whereNotIn('approver_id', $keepApproverIds);
2025-09-04 10:26:28 +08:00
2025-09-09 13:42:00 +08:00
$q->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]);
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
/** 创建待办(去重) */
2025-09-24 11:18:59 +08:00
private function insertApprovalRecord(object $instance, int $stepIndex, int $approverId): void
2025-09-04 10:26:28 +08:00
{
2025-09-24 11:18:59 +08:00
$round = (int)($instance->round ?? 1);
2025-09-09 13:42:00 +08:00
$now = date('Y-m-d H:i:s');
2025-09-24 11:18:59 +08:00
2025-09-09 13:42:00 +08:00
$exists = Db::table('approval_records')
2025-09-24 11:18:59 +08:00
->where('instance_id', (int)$instance->id)
2025-10-12 06:22:29 +00:00
->where('round', $round) // 限当前轮
2025-09-09 13:42:00 +08:00
->where('step_index', $stepIndex)
->where('approver_id', $approverId)
2025-09-24 11:18:59 +08:00
->where('status', 'pending')
->exists();
2025-09-24 11:18:59 +08:00
if ($exists) return;
2025-09-24 11:18:59 +08:00
Db::table('approval_records')->insert([
'instance_id' => (int)$instance->id,
'approver_id' => $approverId,
'step_index' => $stepIndex,
2025-10-12 06:22:29 +00:00
'round' => $round, //
2025-09-24 11:18:59 +08:00
'action' => null,
'status' => 'pending',
'comment' => null,
'approved_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
2025-09-04 10:26:28 +08:00
}
2025-09-19 07:43:48 +00:00
/** 发送通知(支持附加文案:提交说明/申诉/讨论摘要) */
private function sendNotification(array $userIds, object $instance, string $action, bool $isCC, array $extra = []): void
{
$map = [
'pending' => '待审批',
'approve' => '已通过',
'reject' => '已拒绝',
'revoked' => '已撤回',
2025-10-11 08:41:11 +00:00
'voided' => '已作废',
2025-09-19 07:43:48 +00:00
];
$prefix = $isCC ? '【抄送】' : '【审批】';
$text = $map[$action] ?? $action;
// 业务标题/内容
$approvalType = array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('approval_type'));
2025-10-11 08:41:11 +00:00
$flowTxt = $approvalType[$instance->flow_type] ?? (string)$instance->flow_type;
$title = $prefix.'审批通知';
$content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务标识 {$instance->biz_id}";
2025-09-19 07:43:48 +00:00
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':
// 备货单作废
2025-10-10 11:09:37 +00:00
$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']);
}
}
2025-09-19 07:43:48 +00:00
break;
2025-09-23 17:26:01 +08:00
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;
2025-09-19 07:43:48 +00:00
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,
]);
}
2025-09-09 13:42:00 +08:00
private function notifyInitiator(object $instance, string $result): void
2025-09-04 10:26:28 +08:00
{
2025-09-09 13:42:00 +08:00
$this->sendNotification([(int)$instance->initiator_id], $instance, $result, false);
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
// ---------- 数据访问(roles / user_roles 结构) ----------
2025-09-04 10:26:28 +08:00
/**
2025-09-09 13:42:00 +08:00
* 从角色取用户ID
* @param mixed $roleIdOrName 数字 => role_id;字符串 => roles.name
* @param int|null $systemId 限定 roles.system_id;null 不限定
* @param bool $onlyActive 仅取 status=1 角色,默认 true
* @param bool $includeGlobal 是否包含全局角色(system_id=0),默认 true
2025-09-04 10:26:28 +08:00
*/
2025-09-09 13:42:00 +08:00
private function getUserIdsByRole($roleIdOrName, ?int $systemId = null, bool $onlyActive = true, bool $includeGlobal = true): array
2025-09-04 10:26:28 +08:00
{
2025-09-09 13:42:00 +08:00
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);
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
$rows = $q->get();
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
} 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();
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
$ids = [];
foreach ($rows as $row) $ids[] = (int)$row->user_id;
return array_values(array_unique($ids));
2025-09-04 10:26:28 +08:00
}
private function getLeaderIdByUserId(int $userId): ?int
{
2025-09-09 13:42:00 +08:00
$id = Db::table('user')->where('id', $userId)->value('report_to');
return $id ? (int)$id : null;
2025-09-04 10:26:28 +08:00
}
private function getDeptHeadId(int $deptId): ?int
{
2025-09-09 13:42:00 +08:00
$id = Db::table('departments')->where('id', $deptId)->value('head_user_id');
return $id ? (int)$id : null;
2025-09-04 10:26:28 +08:00
}
2025-09-09 13:42:00 +08:00
/**
* 查询某个审批实例的审批记录(附带步骤名/类型,可选带出审批人姓名)
*
* @param int $instanceId
* @param string|null $userNameColumn 可选:如 'name' 或 'nickname'。传 null 则不 join 用户表
* @return array{
* instance: array,
* steps: array<int, array{name:string,type:string,condition?:string}>,
* records: array<int, array{
* id:int, instance_id:int, approver_id:int, step_index:int,
* action:?string, status:string, comment:?string,
* approved_at:?string, created_at:?string, updated_at:?string,
* approver_name?:?string,
* step_name:string, step_type:string,
* }>,
* summary: array{approved:int,rejected:int,pending:int,skipped:int,timeout:int,total:int}
* }
*/
public function getInstanceRecords(int $instanceId, ?string $userNameColumn = null): array
2025-09-04 10:26:28 +08:00
{
2025-09-09 13:42:00 +08:00
// 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,
];
2025-09-04 10:26:28 +08:00
}
2025-09-09 16:15:48 +08:00
2025-09-09 17:30:39 +08:00
2025-09-24 17:38:04 +08:00
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;
2025-10-12 06:22:29 +00:00
// 每一步的“审批意见”(只展示有内容的 comment)
2025-09-24 17:38:04 +08:00
$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,
2025-10-12 06:22:29 +00:00
'opinions' => $opinions, // 这就是每个审批意见
2025-09-24 17:38:04 +08:00
];
// 把申请人信息并到第一步(可选)
if ($initiatorPlacement === 'attach' && empty($timeline)) {
$row['initiator_id'] = $initiatorId;
$row['initiator_name'] = $initiatorName;
}
$timeline[] = $row;
}
return $timeline;
}
2025-09-09 17:30:39 +08:00
/**
* 按业务ID获取“最新进展”的极简状态
* - 已完结:直接返回实例最终状态
* - 进行中:返回当前步骤(索引、名称、类型)与当前待处理审批者列表(pending)
*
* @param string $bizId
* @param string|null $bizType 可选限定业务类型
* @param string|null $userNameColumn 可选:审批人姓名字段(如 user.name / nickname)
* @return array{
* instance_id:int, flow_type:string, biz_type:string, biz_id:string,
* status:string, // 实例状态:approved|rejected|revoked|processing
* finished_at:?string, updated_at:?string,
* step?: array{
* index:int, name:string, type:string, // any|all
* status:string, // processing|timeout(进行中场景)
* pending_approvers: array<int, array{approver_id:int, approver_name?:?string}>
* }
* }
*/
public function getBriefStatusByBiz(string $bizId, ?string $bizType = null, ?string $userNameColumn = null): array
{
// 1) 最新实例
$q = Db::table('approval_instances')->where('biz_id', $bizId);
if ($bizType !== null && $bizType !== '') {
$q->where('biz_type', $bizType);
}
$instance = $q->orderByDesc('id')->first();
if (!$instance) {
// throw new BusinessException('未找到对应的审批实例');
return [];
}
$base = [
'instance_id' => (int)$instance->id,
'flow_type' => $instance->flow_type,
'biz_type' => $instance->biz_type,
'biz_id' => $instance->biz_id,
'status' => $instance->status,
'finished_at' => $instance->finished_at,
'updated_at' => $instance->updated_at,
];
// 2) 已完结:直接返回
if ($instance->status !== 'processing') {
return $base;
}
// 3) 进行中:取当前步骤 + 当前 pending 审批者
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
if (!$flow) {
// 容错:没有流程定义
return $base;
}
$steps = json_decode($flow->steps, true) ?: [];
$idx = (int)$instance->current_step;
2025-09-24 11:18:59 +08:00
$round = (int)$instance->round;
2025-09-09 17:30:39 +08:00
$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)
2025-09-24 11:18:59 +08:00
->where('ar.round', $round)
2025-09-09 17:30:39 +08:00
->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)
2025-09-24 11:18:59 +08:00
->where('round', $round)
2025-09-09 17:30:39 +08:00
->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)
2025-09-24 11:18:59 +08:00
->where('round', $round)
2025-09-09 17:30:39 +08:00
->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;
}
2025-09-19 07:43:48 +00:00
// ====== ====== ====== ====== ====== ====== 重提操作 && 讨论 ====== ====== ====== ====== ====== ======
/**
* 重提交(仅对 status=rejected 的实例;支持申诉说明)
* - 合并 $patchData 到 form_data,并设置 resubmitted=1
* - 若 $appealText 非空:form_data.appeal_last = $appealText(可用于前端展示)
* - 重新计算首个命中的步骤并创建待办;通知审批人且带上申诉说明
*/
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);
}
2025-10-12 06:22:29 +00:00
$oldPendingUids = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('status', 'pending')
->pluck('approver_id')->toArray();
2025-09-19 07:43:48 +00:00
// 清理可能遗留的 pending
Db::table('approval_records')
->where('instance_id', $instanceId)
->where('status', 'pending')
->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]);
2025-10-12 06:22:29 +00:00
// 轮次 +1
2025-09-24 11:18:59 +08:00
$newRound = ((int)$instance->round) + 1;
2025-09-19 07:43:48 +00:00
// 重新开启实例
Db::table('approval_instances')->where('id', $instanceId)->update([
'status' => 'processing',
'current_step' => $firstIndex,
2025-09-24 11:18:59 +08:00
'round' => $newRound,
2025-09-19 07:43:48 +00:00
'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,
2025-09-24 11:18:59 +08:00
'round' => $newRound,
2025-09-19 07:43:48 +00:00
];
$approvers = $this->resolveApprovers($firstStep, $instanceLite, true);
if (empty($approvers)) throw new BusinessException('重提后首步未解析到审批人');
foreach ($approvers as $uid) {
2025-09-24 11:18:59 +08:00
$this->insertApprovalRecord($instanceLite, $firstIndex, (int)$uid);
2025-09-19 07:43:48 +00:00
}
$extra = $appealText !== '' ? ['appeal' => $appealText] : [];
$this->sendNotification($approvers, $instanceLite, 'pending', false, $extra);
$this->notifyCCs($firstStep, $instanceLite, 'pending');
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged(array_merge($oldPendingUids, $approvers));
2025-09-19 07:43:48 +00:00
return [
'status' => 'resubmitted',
'instance_id' => (int)$instanceId,
'current_step' => $firstIndex,
'step_name' => $firstStep['name'] ?? ('Step '.$firstIndex),
];
});
}
/** 是否该实例参与者(发起人/任一步审批人/抄送人) */
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;
}
/**
* 发表讨论
* @param int $instanceId
* @param int $userId
* @param string $content
* @param bool $isInternal true=仅审批人可见;false=所有参与者可见
* @param array $attachments 可选附件数组
* @param string|null $userNameColumn 通知里带发言人姓名(如 'name'),不需要传 null
*/
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,
];
}
/**
* 获取讨论列表
* @param int $instanceId
* @param int|null $afterId 翻页:取 id > afterId 的新消息
* @param int $limit
* @param string|null $userNameColumn 带出用户名(如 'name'),不需要可传 null
*/
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);
}
// ====== ====== ====== ====== ====== ====== 转交操作 ====== ====== ====== ====== ====== ======
/**
* 查看某个实例的转交流水(按时间升序)
*/
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);
}
/**
* 转交当前待办给他人(原地替换 approver_id)并在通知里追加“来源/原因”
*/
public function transferApproval(
int $instanceId,
int $stepIndex,
int $fromUserId,
int $toUserId,
string $reason = '',
?string $userNameColumn = null // 可选:传 'name' 或 'nickname' 来展示姓名
): 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)
2025-09-24 11:18:59 +08:00
->where('round', (int)$instance->round)
2025-09-19 07:43:48 +00:00
->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)
2025-09-24 11:18:59 +08:00
->where('round', (int)$instance->round)
2025-09-19 07:43:48 +00:00
->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,
]);
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged([$fromUserId, $toUserId]);
2025-09-19 07:43:48 +00:00
return ['status' => 'ok', 'record_id' => $effectiveRecordId];
});
}
2025-09-09 17:30:39 +08:00
2025-10-10 11:09:37 +00:00
2025-10-12 06:22:29 +00:00
/** 新增:发起“作废审批” */
2025-10-10 11:09:37 +00:00
public function startNullify(
string $voidFlowType, // 例如 'stock_orders_nullify'
string $bizType, // 原业务类型,如 'stock_orders'
string $bizId, // 原业务ID
array $formData, // 可包含 ['nullify_reason'=>...]
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();
2025-10-11 08:41:11 +00:00
2025-10-10 11:09:37 +00:00
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('已存在进行中的作废审批');
}
2025-10-11 08:41:11 +00:00
// 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('该单据已作废,无需重复作废');
}
*/
2025-10-10 11:09:37 +00:00
// 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,
2025-10-12 06:22:29 +00:00
'round' => 1, //
2025-10-10 11:09:37 +00:00
'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',
2025-10-12 06:22:29 +00:00
'round' => 1, //
2025-10-10 11:09:37 +00:00
];
// 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');
2025-10-12 06:22:29 +00:00
$this->pushTodoChanged($approverIds);
2025-10-10 11:09:37 +00:00
return ['instance_id' => $instanceId];
});
}
// 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;
}
2025-10-11 08:41:11 +00:00
//
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;
}
2025-10-12 06:22:29 +00:00
/** 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());
}
}
/** 取“某实例、某轮次、某步骤”里当前 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)));
}
/** 登录/首屏初始化:获取当前用户的待办总数(仅统计当前步骤、进行中的实例) */
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);
}
/** WebSocket:把一组用户的“待办数量”推送给前端(与 getInitTodoCount 统一口径) */
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) {
2025-10-12 08:59:39 +00:00
(new \app\common\utils\WsEvents())->todoCounts($uid, $cnt);
2025-10-12 06:22:29 +00:00
}
} catch (\Throwable $e) {
Log::error('ws todo push error: '.$e->getMessage());
}
}
2025-09-04 10:26:28 +08:00
}