Files
fdesk/app/common/utils/approval/service/ApprovalFlowService.php
T
2025-11-03 18:07:56 +08:00

1953 lines
83 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\utils\approval\service;
use support\exception\BusinessException;
use support\Db;
use support\Log;
class ApprovalFlowService
{
/** 启动审批流程 */
public function start(string $flowType, string $bizType, string $bizId, array $formData, int $initiatorId): array
{
return Db::transaction(function () use ($flowType, $bizType, $bizId, $formData, $initiatorId) {
$flow = Db::table('approval_flows')->where('type', $flowType)->where('is_active', 1)->first();
if (!$flow) throw new BusinessException("审批流程未找到或已停用: {$flowType}");
$steps = json_decode($flow->steps, true);
if (!is_array($steps) || empty($steps)) throw new BusinessException("流程步骤配置错误");
// 找首个有效步骤索引(考虑条件)
$firstIndex = $this->getNextStepIndex($steps, $formData, -1);
if ($firstIndex === null) throw new BusinessException("未找到可执行的审批步骤");
$firstStep = $steps[$firstIndex];
$now = date('Y-m-d H:i:s');
$deadlineAt = null;
if (($flow->timeout_hours ?? 0) > 0) {
$deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600);
}
$instanceId = Db::table('approval_instances')->insertGetId([
'flow_type' => $flowType,
'biz_type' => $bizType,
'biz_id' => $bizId,
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'initiator_id' => $initiatorId,
'status' => 'processing',
'current_step' => $firstIndex,
'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)];
});
}
// 流程完结后的业务回调(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());
}
}
/** 撤回(仅发起人在无已处理记录前可撤回) */
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'];
});
}
/** 流程图数据(把 skipped/timeout 视作已完结的中性状态) */
public function getFlowDiagramData(int $instanceId): array
{
$instance = Db::table('approval_instances')->find($instanceId);
if (!$instance) return [];
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
if (!$flow) return [];
$steps = json_decode($flow->steps, true) ?: [];
$records = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('round', (int)$instance->round)
->orderBy('step_index')->orderByRaw('COALESCE(approved_at, created_at) asc')
->get()
->groupBy('step_index');
$formData = json_decode($instance->form_data, true) ?: [];
$data = [];
foreach ($steps as $i => $step) {
$actions = ($records->get($i) ?? collect())->toArray();
if (!empty($actions)) {
$cntApproved = 0; $cntRejected = 0; $cntPending = 0;
foreach ($actions as $a) {
if ($a->status === 'approved') $cntApproved++;
elseif ($a->status === 'rejected') $cntRejected++;
elseif ($a->status === 'pending') $cntPending++;
// skipped/timeout 不阻断完结显示
}
if ($cntRejected > 0) $status = 'rejected';
elseif ($cntPending > 0) $status = 'processing';
elseif ($cntApproved > 0) $status = 'approved';
else $status = 'approved'; // 全是 skipped/timeout 也视为已过
} else {
if ($i < (int)$instance->current_step) {
if (isset($step['condition']) && !$this->evaluateCondition($step['condition'], $formData)) {
$status = 'skipped';
} else {
$status = 'approved';
}
} elseif ($i === (int)$instance->current_step && $instance->status === 'processing') {
$status = 'processing';
} else {
$status = 'pending';
}
}
$data[] = [
'index' => $i,
'name' => $step['name'] ?? ('Step '.$i),
'type' => $step['type'] ?? 'all',
'status' => $status,
'actions' => $actions,
];
}
return $data;
}
/** 定时任务:处理超时(实例级) */
public function 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);
});
}
}
// ---------- 内部工具 ----------
/** 解析审批人(role/user/initiator_leader/field_user/dept_head) */
private function resolveApprovers(array $step, object $instance, bool $avoidSelf = true): array
{
$resolved = [];
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
$confs = $step['approvers'] ?? [];
foreach ($confs as $cfg) {
$typ = $cfg['type'] ?? '';
$val = $cfg['value'] ?? null;
$ids = [];
switch ($typ) {
case 'role':
$sys = isset($cfg['system_id']) ? (int)$cfg['system_id'] : null;
$includeGlobal = array_key_exists('include_global', $cfg) ? (bool)$cfg['include_global'] : true;
$ids = $this->getUserIdsByRole($val, $sys, true, $includeGlobal);
break;
case 'user':
$ids = [(int)$val];
break;
case 'initiator_leader':
$leaderId = $this->getLeaderIdByUserId((int)$instance->initiator_id);
if ($leaderId) $ids = [(int)$leaderId];
break;
case 'field_user':
if (isset($formData[$val])) $ids = [(int)$formData[$val]];
break;
case 'dept_head':
$head = $this->getDeptHeadId((int)$val);
if ($head) $ids = [(int)$head];
break;
case 'group_members': {
// $gid = (int)($cfg['group_id'] ?? 0);
$gid = (int)($cfg['value'] ?? 0);
if ($gid <= 0) throw new BusinessException('group_members 需要 group_id');
$ids = $this->getGroupMemberIds($gid); // A 方案
break;
}
case 'scenario_leader_of_initiator': {
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
// $scenario = $cfg['scenario'] ?? ($formData[$cfg['scenario_field']] ?? null);
$scenario = $val ?? ($formData[$cfg['scenario_field']] ?? null);
if (!$scenario) throw new BusinessException('缺少业务场景(scenario 或 scenario_field)');
$mustBelong = array_key_exists('must_belong', $cfg) ? (bool)$cfg['must_belong'] : true;
$preferPrimary = array_key_exists('prefer_primary', $cfg) ? (bool)$cfg['prefer_primary'] : true;
$ids = $this->getLeadersByScenarioForInitiator(
(int)$instance->initiator_id,
(string)$scenario,
$mustBelong,
$preferPrimary
); // B 方案(按场景)
break;
}
default:
throw new BusinessException("不支持的审批人类型: {$typ}");
}
$resolved = array_merge($resolved, $ids);
}
$resolved = array_unique(array_filter(array_map('intval', $resolved)));
if ($avoidSelf) {
// $resolved = array_values(array_filter($resolved, fn($id) => $id !== (int)$instance->initiator_id));
$resolved = array_values($resolved);
}
if (empty($resolved)) {
throw new BusinessException("未解析到任何审批人");
}
return $resolved;
}
/** 通知抄送人 */
private function notifyCCs(array $step, object $instance, string $action = 'pending'): void
{
$ccConfig = $step['cc'] ?? [];
if (empty($ccConfig)) return;
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
$uids = [];
foreach ($ccConfig as $cc) {
$typ = $cc['type'] ?? '';
$val = $cc['value'] ?? null;
switch ($typ) {
case 'role':
$uids = array_merge($uids, $this->getUserIdsByRole($val, null, true, true));
break;
case 'user': $uids[] = (int)$val; break;
case 'initiator_leader':
$leaderId = $this->getLeaderIdByUserId((int)$instance->initiator_id);
if ($leaderId) $uids[] = (int)$leaderId;
break;
case 'field_user':
if (isset($formData[$val])) $uids[] = (int)$formData[$val];
break;
case 'dept_head':
$head = $this->getDeptHeadId((int)$val);
if ($head) $uids[] = (int)$head;
break;
case 'group_members': {
$gid = (int)($val ?? 0);
if ($gid <= 0) throw new BusinessException('group_members 需要 group_id');
$uids = $this->getGroupMemberIds($gid); // A 方案
break;
}
case 'scenario_leader_of_initiator': {
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
$scenario = $val ?? ($formData[$cc['scenario_field']] ?? null);
if (!$scenario) throw new BusinessException('缺少业务场景(scenario 或 scenario_field)');
$mustBelong = array_key_exists('must_belong', $cc) ? (bool)$cc['must_belong'] : true;
$preferPrimary = array_key_exists('prefer_primary', $cc) ? (bool)$cc['prefer_primary'] : true;
$uids = $this->getLeadersByScenarioForInitiator(
(int)$instance->initiator_id,
(string)$scenario,
$mustBelong,
$preferPrimary
); // B 方案(按场景)
break;
}
default: break;
}
}
$uids = array_unique(array_filter(array_map('intval', $uids)));
if (empty($uids)) return;
$now = date('Y-m-d H:i:s');
foreach ($uids as $uid) {
$exists = Db::table('approval_ccs')
->where('instance_id', $instance->id)
->where('user_id', $uid)
->count() > 0;
if (!$exists) {
Db::table('approval_ccs')->insert([
'instance_id' => $instance->id,
'user_id' => $uid,
'notified_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
$this->sendNotification($uids, $instance, $action, true);
}
/** 条件求值(简单表达式) */
private function evaluateCondition(?string $condition, array $formData): bool
{
if (!$condition) return true;
$pattern = '/^(\w+)\s*(==|!=|>|<|>=|<=)\s*(.+)$/';
if (!preg_match($pattern, $condition, $m)) {
throw new BusinessException("条件格式错误: {$condition}");
}
$field = $m[1]; $op = $m[2]; $value = trim($m[3], "\"'");
$actual = $formData[$field] ?? null;
if (is_numeric($value) && is_numeric($actual)) { $value=(float)$value; $actual=(float)$actual; }
return match ($op) {
'==' => $actual == $value,
'!=' => $actual != $value,
'>' => $actual > $value,
'<' => $actual < $value,
'>=' => $actual >= $value,
'<=' => $actual <= $value,
default => throw new BusinessException("不支持的操作符: {$op}"),
};
}
/** 获取下一个有效步骤索引(考虑 condition) */
private function getNextStepIndex(array $steps, array $formData, int $currentStepIndex): ?int
{
for ($i = $currentStepIndex + 1; $i < count($steps); $i++) {
$s = $steps[$i];
if (!isset($s['condition']) || $this->evaluateCondition($s['condition'], $formData)) {
return $i;
}
}
return null;
}
/**
* 聚合本步骤结论(按类型)
*/
private function getStepOutcome(int $instanceId, int $round, int $stepIndex, string $type): string
{
$records = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('round', $round) //
->where('step_index', $stepIndex)
->get();
$pending = $approved = $rejected = $timeout = $skipped = 0;
foreach ($records as $r) {
switch ($r->status) {
case 'pending': $pending++; break;
case 'approved': $approved++; break;
case 'rejected': $rejected++; break;
case 'timeout': $timeout++; break;
case 'skipped': $skipped++; break;
}
}
$type = strtolower($type);
if ($type === 'any') {
if ($rejected > 0) return 'reject';
if ($approved > 0) return 'approve';
// 全为 skipped/timeout 且无 pending/rejected ⇒ 通过
if ($pending === 0 && $rejected === 0 && $approved === 0) return 'approve';
return 'pending';
}
if ($rejected > 0) return 'reject';
if ($pending > 0) return 'pending';
return 'approve';
}
/** 关闭本步其余待办为 skipped */
private function closeOtherPending(int $instanceId, int $round, int $stepIndex, array $keepApproverIds = []): void
{
$now = date('Y-m-d H:i:s');
$q = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('round', $round) //
->where('step_index', $stepIndex)
->where('status', 'pending');
if (!empty($keepApproverIds)) $q->whereNotIn('approver_id', $keepApproverIds);
$q->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]);
}
/** 创建待办(去重) */
private function insertApprovalRecord(object $instance, int $stepIndex, int $approverId): void
{
$round = (int)($instance->round ?? 1);
$now = date('Y-m-d H:i:s');
$exists = Db::table('approval_records')
->where('instance_id', (int)$instance->id)
->where('round', $round) // 限当前轮
->where('step_index', $stepIndex)
->where('approver_id', $approverId)
->where('status', 'pending')
->exists();
if ($exists) return;
Db::table('approval_records')->insert([
'instance_id' => (int)$instance->id,
'approver_id' => $approverId,
'step_index' => $stepIndex,
'round' => $round, //
'action' => null,
'status' => 'pending',
'comment' => null,
'approved_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
/** 发送通知(支持附加文案:提交说明/申诉/讨论摘要) */
private function sendNotification(array $userIds, object $instance, string $action, bool $isCC, array $extra = []): void
{
$map = [
'pending' => '待审批',
'approve' => '已通过',
'reject' => '已拒绝',
'revoked' => '已撤回',
'voided' => '已作废',
];
$prefix = $isCC ? '【抄送】' : '【审批】';
$text = $map[$action] ?? $action;
// 业务标题/内容
$approvalType = array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('approval_type'));
$flowTxt = $approvalType[$instance->flow_type] ?? (string)$instance->flow_type;
$title = $prefix.'审批通知';
$content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务标识 {$instance->biz_id}";
switch ($instance->flow_type) {
case 'stock_orders':
// 备货单
$stockOrder = \app\common\model\StockOrders::find($instance->biz_id);
if ($stockOrder) {
$title = $prefix."备货单:{$stockOrder->order_no}";
$flowTxt = $approvalType[$instance->flow_type] ?? $instance->flow_type;
$content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$stockOrder->order_no}";
}
break;
case 'stock_orders_nullify':
// 备货单作废
$stockOrder = \app\common\model\StockOrders::find($instance->biz_id);
if ($stockOrder) {
$title = $prefix."备货单作废:{$stockOrder->order_no}";
$flowTxt = $approvalType[$instance->flow_type] ?? '备货单作废';
$content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$stockOrder->order_no}";
// 把作废原因附带展示更清晰(也可依赖 $extra['note'])
$fd = json_decode($instance->form_data ?? '{}', true) ?: [];
if (!empty($fd['_nullify_reason'])) {
$content .= ';原因:'.(function_exists('mb_strimwidth') ? mb_strimwidth($fd['_nullify_reason'], 0, 120, '...', 'UTF-8') : $fd['_nullify_reason']);
}
}
break;
case 'purchase_orders':
// 采购单
$purchaseOrder = \app\common\model\PurchaseOrders::find($instance->biz_id);
if ($purchaseOrder) {
$title = $prefix."采购单:{$purchaseOrder->order_no}";
$flowTxt = $approvalType[$instance->flow_type] ?? $instance->flow_type;
$content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$purchaseOrder->order_no}";
}
break;
case 'purchase_order':
// 采购单
break;
}
// 附加说明(提交说明/申诉/讨论摘要)
$append = $extra['appeal'] ?? ($extra['note'] ?? null);
if ($append) {
if (function_exists('mb_strimwidth')) {
$append = mb_strimwidth($append, 0, 120, '...', 'UTF-8');
}
$content .= ";说明:{$append}";
}
noticePush([
'title' => $title,
'content' => $content,
'recipient' => $userIds,
'email' => 1,
]);
}
private function notifyInitiator(object $instance, string $result): void
{
$this->sendNotification([(int)$instance->initiator_id], $instance, $result, false);
}
// ---------- 数据访问(roles / user_roles 结构) ----------
/**
* 从角色取用户ID
* @param mixed $roleIdOrName 数字 => role_id;字符串 => roles.name
* @param int|null $systemId 限定 roles.system_id;null 不限定
* @param bool $onlyActive 仅取 status=1 角色,默认 true
* @param bool $includeGlobal 是否包含全局角色(system_id=0),默认 true
*/
private function getUserIdsByRole($roleIdOrName, ?int $systemId = null, bool $onlyActive = true, bool $includeGlobal = true): array
{
if (is_int($roleIdOrName) || (is_string($roleIdOrName) && ctype_digit($roleIdOrName))) {
$roleId = (int)$roleIdOrName;
$needJoin = $onlyActive || !is_null($systemId) || $includeGlobal === false;
if (!$needJoin) {
$rows = Db::table('user_roles')->where('role_id', $roleId)->select('user_id')->get();
} else {
$q = Db::table('user_roles as ur')
->join('roles as r', 'ur.role_id', '=', 'r.id')
->where('ur.role_id', $roleId)
->select('ur.user_id');
if ($onlyActive) $q->where('r.status', 1);
if (!is_null($systemId)) {
$includeGlobal ? $q->whereIn('r.system_id', [0, $systemId]) : $q->where('r.system_id', $systemId);
}
$rows = $q->get();
}
} else {
$name = (string)$roleIdOrName;
$q = Db::table('user_roles as ur')
->join('roles as r', 'ur.role_id', '=', 'r.id')
->where('r.name', $name)
->select('ur.user_id');
if ($onlyActive) $q->where('r.status', 1);
if (!is_null($systemId)) {
$includeGlobal ? $q->whereIn('r.system_id', [0, $systemId]) : $q->where('r.system_id', $systemId);
}
$rows = $q->get();
}
$ids = [];
foreach ($rows as $row) $ids[] = (int)$row->user_id;
return array_values(array_unique($ids));
}
private function getLeaderIdByUserId(int $userId): ?int
{
$id = Db::table('user')->where('id', $userId)->value('report_to');
return $id ? (int)$id : null;
}
private function getDeptHeadId(int $deptId): ?int
{
$id = Db::table('departments')->where('id', $deptId)->value('head_user_id');
return $id ? (int)$id : null;
}
/**
* 查询某个审批实例的审批记录(附带步骤名/类型,可选带出审批人姓名)
*
* @param int $instanceId
* @param string|null $userNameColumn 可选:如 'name' 或 'nickname'。传 null 则不 join 用户表
* @return array{
* instance: array,
* steps: array<int, array{name:string,type:string,condition?:string}>,
* records: array<int, array{
* id:int, instance_id:int, approver_id:int, step_index:int,
* action:?string, status:string, comment:?string,
* approved_at:?string, created_at:?string, updated_at:?string,
* approver_name?:?string,
* step_name:string, step_type:string,
* }>,
* summary: array{approved:int,rejected:int,pending:int,skipped:int,timeout:int,total:int}
* }
*/
public function getInstanceRecords(int $instanceId, ?string $userNameColumn = null): array
{
// 1) 实例 + 流程步骤
$instance = Db::table('approval_instances')->where('id', $instanceId)->first();
if (!$instance) {
throw new BusinessException('审批实例未找到');
}
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
if (!$flow) {
throw new BusinessException('审批流程定义未找到');
}
$steps = json_decode($flow->steps, true) ?: [];
// 2) 记录查询(可选带出审批人姓名)
if ($userNameColumn) {
// 注意:确保 user 表存在该列
$records = Db::table('approval_records as ar')
->leftJoin('user as u', 'ar.approver_id', '=', 'u.id')
->where('ar.instance_id', $instanceId)
->orderBy('ar.step_index')
->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc')
->select(
'ar.*',
Db::raw("u.`{$userNameColumn}` as approver_name")
)
->get()
->toArray();
} else {
$records = Db::table('approval_records as ar')
->where('ar.instance_id', $instanceId)
->orderBy('ar.step_index')
->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc')
->select('ar.*')
->get()
->toArray();
}
// 3) 附加步骤名/类型;统计汇总
$summary = ['approved'=>0,'rejected'=>0,'pending'=>0,'skipped'=>0,'timeout'=>0,'total'=>0];
foreach ($records as &$r) {
$idx = (int)$r->step_index;
$meta = $steps[$idx] ?? [];
$r->step_name = $meta['name'] ?? ('Step '.$idx);
$r->step_type = $meta['type'] ?? 'all';
if (isset($summary[$r->status])) {
$summary[$r->status]++;
}
$summary['total']++;
}
unset($r);
return [
'instance' => [
'id' => (int)$instance->id,
'flow_type' => $instance->flow_type,
'biz_type' => $instance->biz_type,
'biz_id' => $instance->biz_id,
'status' => $instance->status,
'current_step' => (int)$instance->current_step,
'created_at' => $instance->created_at,
'updated_at' => $instance->updated_at,
'finished_at' => $instance->finished_at,
],
'steps' => array_map(function($s){
return [
'name' => $s['name'] ?? '',
'type' => $s['type'] ?? 'all',
'condition' => $s['condition'] ?? null,
];
}, $steps),
'records' => array_map(function($r){
// 转成数组(Illuminate 返回的是 stdClass)
return [
'id' => (int)$r->id,
'instance_id' => (int)$r->instance_id,
'approver_id' => (int)$r->approver_id,
'step_index' => (int)$r->step_index,
'action' => $r->action,
'status' => $r->status,
'comment' => $r->comment,
'approved_at' => $r->approved_at,
'created_at' => $r->created_at,
'updated_at' => $r->updated_at,
'approver_name'=> property_exists($r, 'approver_name') ? $r->approver_name : null,
'step_name' => $r->step_name,
'step_type' => $r->step_type,
];
}, $records),
'summary' => $summary,
];
}
public function getSimpleTimeline(int $instanceId, ?string $userNameColumn = null, string $initiatorPlacement = 'prepend'): array
{
// 基础数据
$instance = Db::table('approval_instances')->where('id', $instanceId)->first();
if (!$instance) throw new BusinessException('审批实例未找到');
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
if (!$flow) throw new BusinessException('审批流程定义未找到');
$steps = json_decode($flow->steps, true) ?: [];
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
$curStep = (int)$instance->current_step;
$round = (int)$instance->round;
// 申请人
$initiatorId = (int)$instance->initiator_id;
$initiatorName = null;
if ($userNameColumn) {
$initiatorName = Db::table('user')->where('id', $initiatorId)->value($userNameColumn);
}
// 审批记录(仅当前轮次)
$records = Db::table('approval_records as ar')
->where('ar.instance_id', $instanceId)
->where('ar.round', $round)
->orderBy('ar.step_index')
->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc')
->select('ar.*')
->get()
->toArray();
// 一次性拉取姓名,避免 N+1
$nameMap = [];
if ($userNameColumn && !empty($records)) {
$uids = [];
foreach ($records as $r) $uids[] = (int)$r->approver_id;
$uids = array_values(array_unique($uids));
if (!empty($uids)) {
$pairs = Db::table('user')->whereIn('id', $uids)->pluck($userNameColumn, 'id')->toArray();
foreach ($pairs as $uid => $name) $nameMap[(int)$uid] = $name;
}
}
// 记录分组
$byStep = [];
foreach ($records as $r) {
$idx = (int)$r->step_index;
$byStep[$idx][] = $r;
}
$timeline = [];
// 可选:发起申请块
if ($initiatorPlacement === 'prepend') {
$appliedAt = $instance->created_at ?: null;
$timeline[] = [
'index' => -1,
'name' => '发起申请',
'type' => '-',
'status' => 'submitted',
'started_at' => $appliedAt,
'finished_at' => $appliedAt,
'duration_seconds' => 0,
'initiator_id' => $initiatorId,
'initiator_name' => $initiatorName,
];
}
// 每一步
foreach ($steps as $i => $meta) {
$name = $meta['name'] ?? ('Step '.$i);
$typeRaw = strtolower($meta['type'] ?? 'all');
$type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all';
$condStr = $meta['condition'] ?? null;
$condOk = $condStr ? $this->evaluateCondition($condStr, $formData) : true;
$recs = $byStep[$i] ?? [];
$hasRecs = !empty($recs);
$status = 'pending';
$startedAt = null;
$finishedAt = null;
$durationSec = null;
// 每一步的“审批意见”(只展示有内容的 comment)
$opinions = []; // [{approver_id, approver_name?, action, status, comment, time}...]
if ($hasRecs) {
// 统计时间
$minCreated = null;
$minApproveAt = null; $maxApproveAt = null;
$minRejectAt = null;
$maxTimeoutAt = null;
$cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0];
foreach ($recs as $rr) {
// 收集“审批意见”(非空 comment 都算)
$cmt = trim((string)($rr->comment ?? ''));
if ($cmt !== '') {
$opinions[] = [
'approver_id' => (int)$rr->approver_id,
'approver_name' => $userNameColumn ? ($nameMap[(int)$rr->approver_id] ?? null) : null,
'action' => $rr->action, // approve | reject | null
'status' => $rr->status, // approved|rejected|pending|skipped|timeout
'comment' => $cmt,
'time' => $rr->approved_at ?: $rr->created_at,
];
}
if ($rr->created_at && (!$minCreated || $rr->created_at < $minCreated)) $minCreated = $rr->created_at;
if ($rr->approved_at) {
if ($rr->status === 'approved') {
if (!$minApproveAt || $rr->approved_at < $minApproveAt) $minApproveAt = $rr->approved_at;
if (!$maxApproveAt || $rr->approved_at > $maxApproveAt) $maxApproveAt = $rr->approved_at;
} elseif ($rr->status === 'rejected') {
if (!$minRejectAt || $rr->approved_at < $minRejectAt) $minRejectAt = $rr->approved_at;
} elseif ($rr->status === 'timeout') {
if (!$maxTimeoutAt || $rr->approved_at > $maxTimeoutAt) $maxTimeoutAt = $rr->approved_at;
}
}
if (isset($cnt[$rr->status])) $cnt[$rr->status]++;
}
// 时间升序排序 opinions
if (!empty($opinions)) {
usort($opinions, function($a, $b) {
return strcmp($a['time'] ?? '', $b['time'] ?? '');
});
}
$startedAt = $minCreated;
// 总结果(注意:这里按你现在的签名调用,含 round)
$outcome = $this->getStepOutcome($instanceId, $round, $i, $type); // 'pending'|'approve'|'reject'
if ($outcome === 'reject') {
$status = 'rejected';
$finishedAt = $minRejectAt;
} elseif ($outcome === 'approve') {
$status = 'approved';
$finishedAt = ($type === 'any') ? $minApproveAt : $maxApproveAt;
} else {
if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) {
$status = 'timeout';
$finishedAt = $maxTimeoutAt;
} else {
$status = 'processing';
}
}
if ($startedAt && $finishedAt) {
$durationSec = max(0, strtotime($finishedAt) - strtotime($startedAt));
}
} else {
// 无记录:根据位置与条件展示
if ($i < $curStep) {
$status = $condOk ? 'approved' : 'skipped';
} elseif ($i === $curStep && $instance->status === 'processing') {
$status = 'processing';
} else {
$status = $condOk ? 'pending' : 'skipped';
}
}
$row = [
'index' => (int)$i,
'name' => $name,
'type' => $type,
'status' => $status,
'started_at' => $startedAt,
'finished_at' => $finishedAt,
'duration_seconds' => $durationSec,
'opinions' => $opinions, // 这就是每个审批意见
];
// 把申请人信息并到第一步(可选)
if ($initiatorPlacement === 'attach' && empty($timeline)) {
$row['initiator_id'] = $initiatorId;
$row['initiator_name'] = $initiatorName;
}
$timeline[] = $row;
}
return $timeline;
}
/**
* 按业务ID获取“最新进展”的极简状态
* - 已完结:直接返回实例最终状态
* - 进行中:返回当前步骤(索引、名称、类型)与当前待处理审批者列表(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;
$round = (int)$instance->round;
$meta = $steps[$idx] ?? [];
$typeRaw = strtolower($meta['type'] ?? 'all');
$type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all';
// 当前步的所有记录
$recsQ = Db::table('approval_records')->where('instance_id', $instance->id)->where('step_index', $idx);
// 待处理人(只列出 pending)
if ($userNameColumn) {
$pendingRows = Db::table('approval_records as ar')
->leftJoin('user as u', 'ar.approver_id', '=', 'u.id')
->where('ar.instance_id', $instance->id)
->where('ar.round', $round)
->where('ar.step_index', $idx)
->where('ar.status', 'pending')
->select('ar.approver_id', Db::raw("u.`{$userNameColumn}` as approver_name"))
->get()
->toArray();
} else {
$pendingRows = Db::table('approval_records')
->where('instance_id', $instance->id)
->where('round', $round)
->where('step_index', $idx)
->where('status', 'pending')
->select('approver_id')
->get()
->toArray();
}
$pendingApprovers = [];
foreach ($pendingRows as $r) {
$row = ['approver_id' => (int)$r->approver_id];
if ($userNameColumn && property_exists($r, 'approver_name')) {
$row['approver_name'] = $r->approver_name;
}
$pendingApprovers[] = $row;
}
// 当前步的聚合状态(进行中通常返回 processing;若全被超时任务置为 timeout,则返回 timeout)
// 先快速统计是否纯 timeout
$stats = Db::table('approval_records')
->where('instance_id', $instance->id)
->where('round', $round)
->where('step_index', $idx)
->selectRaw("SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) as c_pending")
->selectRaw("SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as c_approved")
->selectRaw("SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as c_rejected")
->selectRaw("SUM(CASE WHEN status='timeout' THEN 1 ELSE 0 END) as c_timeout")
->first();
$stepStatus = 'processing';
if ($stats && (int)$stats->c_timeout > 0 && (int)$stats->c_pending === 0 && (int)$stats->c_approved === 0 && (int)$stats->c_rejected === 0) {
$stepStatus = 'timeout';
}
$base['step'] = [
'index' => $idx,
'name' => $meta['name'] ?? ('Step '.$idx),
'type' => $type, // any|all
'status' => $stepStatus, // processing|timeout
'pending_approvers' => $pendingApprovers, // 当前正在等待的审批者
];
return $base;
}
// ====== ====== ====== ====== ====== ====== 重提操作 && 讨论 ====== ====== ====== ====== ====== ======
/**
* 重提交(仅对 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);
}
$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),
];
});
}
/** 是否该实例参与者(发起人/任一步审批人/抄送人) */
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)
->where('round', (int)$instance->round)
->where('step_index', $stepIndex)
->where('approver_id', $fromUserId)
->where('status', 'pending')
->lockForUpdate()
->first();
if (!$record) throw new BusinessException('未找到可转交的待办或已处理');
$now = date('Y-m-d H:i:s');
// 若目标已有 pending:合并(本记录置为 skipped)
$target = Db::table('approval_records')
->where('instance_id', $instanceId)
->where('round', (int)$instance->round)
->where('step_index', $stepIndex)
->where('approver_id', $toUserId)
->where('status', 'pending')
->lockForUpdate()
->first();
if ($target) {
Db::table('approval_records')->where('id', $record->id)->update([
'status' => 'skipped',
'approved_at' => $now,
'updated_at' => $now,
'comment' => trim(($record->comment ?? '')."\n[转交给 {$toUserId},目标已有待办,已合并]"),
]);
$effectiveRecordId = (int)$target->id;
} else {
// 原地替换 approver_id
Db::table('approval_records')->where('id', $record->id)->update([
'approver_id' => $toUserId,
'updated_at' => $now,
'comment' => trim(($record->comment ?? '')."\n[自 {$fromUserId} 转交]"),
]);
$effectiveRecordId = (int)$record->id;
}
// 组织“转交来源/原因”文案(优先姓名)
$fromLabel = (string)$fromUserId;
$toLabel = (string)$toUserId;
if ($userNameColumn) {
$fromName = Db::table('user')->where('id', $fromUserId)->value($userNameColumn);
$toName = Db::table('user')->where('id', $toUserId)->value($userNameColumn);
if ($fromName) $fromLabel = $fromName;
if ($toName) $toLabel = $toName;
}
$note = "【转交】{$fromLabel} → {$toLabel}";
$r = trim($reason);
if ($r !== '') $note .= ",原因:{$r}";
// 通知被转交人(在内容里追加 note)
$this->sendNotification([$toUserId], (object)[
'id' => (int)$instance->id,
'flow_type' => (string)$instance->flow_type,
'biz_type' => (string)$instance->biz_type,
'biz_id' => (string)$instance->biz_id,
'initiator_id' => (int)$instance->initiator_id,
'form_data' => $instance->form_data,
], 'pending', false, ['note' => $note]);
// 记转交流水
Db::table('approval_transfers')->insert([
'instance_id' => (int)$instanceId,
'step_index' => (int)$stepIndex,
'record_id' => $effectiveRecordId,
'from_user_id' => (int)$fromUserId,
'to_user_id' => (int)$toUserId,
'reason' => $reason,
'created_at' => $now,
]);
$this->pushTodoChanged([$fromUserId, $toUserId]);
return ['status' => 'ok', 'record_id' => $effectiveRecordId];
});
}
/** 新增:发起“作废审批” */
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();
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];
});
}
// 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;
}
//
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;
}
/** 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) {
(new \app\common\utils\WsEvents())->todoCounts($uid, $cnt);
}
} catch (\Throwable $e) {
Log::error('ws todo push error: '.$e->getMessage());
}
}
}