diff --git a/app/common/controller/PurchaseOrdersController.php b/app/common/controller/PurchaseOrdersController.php index 3e550ac..d880dab 100644 --- a/app/common/controller/PurchaseOrdersController.php +++ b/app/common/controller/PurchaseOrdersController.php @@ -128,7 +128,7 @@ class PurchaseOrdersController extends BaseController { try { $raw = $this->service->serviceResubmit($request, $request->all()); - return $raw === true ? $this->success('提交成功') : $this->error('提交失败'); + return $this->success($raw); } catch (\Throwable $e) { return $this->error($e->getMessage()); } diff --git a/app/common/model/ApprovalInstances.php b/app/common/model/ApprovalInstances.php index 882fa8e..a7a2454 100644 --- a/app/common/model/ApprovalInstances.php +++ b/app/common/model/ApprovalInstances.php @@ -59,6 +59,7 @@ class ApprovalInstances extends Model 'current_step', 'finished_at', 'deadline_at', + 'round' ]; protected $casts = [ diff --git a/app/common/model/ApprovalRecords.php b/app/common/model/ApprovalRecords.php index 660f1a4..88a820e 100644 --- a/app/common/model/ApprovalRecords.php +++ b/app/common/model/ApprovalRecords.php @@ -57,6 +57,7 @@ class ApprovalRecords extends Model 'status', 'comment', 'approved_at', + 'round' ]; protected $casts = [ diff --git a/app/common/service/PurchaseOrdersService.php b/app/common/service/PurchaseOrdersService.php index 5ed8f6f..54be28b 100644 --- a/app/common/service/PurchaseOrdersService.php +++ b/app/common/service/PurchaseOrdersService.php @@ -166,7 +166,6 @@ class PurchaseOrdersService case 'index': $config['order']['id'] = 'desc'; break; - case 'show': case 'update': $config['where']['id'] = $p['id'] ?? 0; break; @@ -214,13 +213,15 @@ class PurchaseOrdersService */ protected function afterServiceStore($res) { - if ($res->status == 1) { - $this->startApproval($res, ['resubmitted'=>0]); - } + // if ($res->status == 1) { + // $this->startApproval($res, ['resubmitted'=>0]); + // } + + $this->startApproval($res, ['resubmitted'=>0]); return $res; } - public function startApproval($item, $formData) + public function startApproval($item) { $appro = new \app\common\utils\approval\service\ApprovalFlowService(); $appro->start('purchase_orders', 'purchase_orders', $item->id, ['resubmitted'=>0], $item->buyer_id); @@ -229,20 +230,45 @@ class PurchaseOrdersService public function serviceResubmit(Request $request, array $data = []) { - $info = $this->logicShow($this->buildQueryConfig($request, 'show')); - if ($info->status == 3) { - $instance = \app\common\model\ApprovalInstances::where('biz_id', $info->id)->where('flow_type', 'purchase_orders')->first(); - if ( $instance === null) { - new BusinessException('未找到审批实例'); - } - // 审批服务 - $appro = new \app\common\utils\approval\service\ApprovalFlowService(); - // 重新提交 - $appro->resubmit($instance->id, $instance->initiator_id, ['resubmitted'=>1], $data['remark']); - // 添加到讨论 - $appro->postDiscussion($instance->id, $instance->initiator_id, $data['remark'], true, [], 'name'); - return true; + $p = $request->all(); + $config = []; + $config['model'] = $this->modelClass; + $config['where']['id'] = $p['id']; + $config['where']['buyer_id'] = $request->user->uid; + $info = $this->logicShow($config); + if ($info === null) { + throw new BusinessException('未找到采购单'); } - return false; + if ($info->status != 3) { + throw new BusinessException('该状态不能重新提交'); + } + $instance = \app\common\model\ApprovalInstances::where('biz_id', $info->id)->where('flow_type', 'purchase_orders')->first(); + if ( $instance === null) { + throw new BusinessException('未找到审批实例'); + } + // 审批服务 + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + // 重新提交 + $result = $appro->resubmit($instance->id, $instance->initiator_id, ['resubmitted'=>1], $data['remark']); + // 添加到讨论 + if (!empty($data['remark'])) { + $appro->postDiscussion($instance->id, $instance->initiator_id, $data['remark'], false, [], 'name'); + } + + $info->status = 1; + $info->save(); + return $result; + } + + + public function updateStatus(array $data = []) + { + + $info = PurchaseOrders::where('id', $data['id'])->first(); + if ($info === null) { + throw new BusinessException('未找到采购单'); + } + $info->status = $data['status']; + return $info->save(); } } \ No newline at end of file diff --git a/app/common/utils/approval/service/ApprovalFlowService.php b/app/common/utils/approval/service/ApprovalFlowService.php index e9832ac..6f878b3 100644 --- a/app/common/utils/approval/service/ApprovalFlowService.php +++ b/app/common/utils/approval/service/ApprovalFlowService.php @@ -35,6 +35,7 @@ class ApprovalFlowService 'initiator_id' => $initiatorId, 'status' => 'processing', 'current_step' => $firstIndex, + 'round' => 1, 'deadline_at' => $deadlineAt, 'created_at' => $now, 'updated_at' => $now, @@ -50,16 +51,15 @@ class ApprovalFlowService '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($instanceId, $firstIndex, $uid); + $this->insertApprovalRecord($instance, $firstIndex, $uid); } - // $this->sendNotification($approverIds, $instance, 'pending', false); - $extra = []; $submitNote = $formData['submit_note'] ?? ''; if (is_string($submitNote) && $submitNote !== '') { @@ -84,8 +84,6 @@ class ApprovalFlowService $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); if (!$instance) throw new BusinessException("审批实例未找到"); if ($instance->status !== 'processing') { - // return ['status' => 'noop', 'reason' => 'instance_not_processing', 'instance_status' => $instance->status]; - // throw new BusinessException("实例未处理"); return ['status' => 'noop', 'msg' => '实例未处理']; } @@ -94,12 +92,14 @@ class ApprovalFlowService $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') @@ -110,21 +110,17 @@ class ApprovalFlowService // 已处理过? $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', 'reason' => 'already_handled', 'record_status' => $anyRecord->status]; - // throw new BusinessException("已处理"); return ['status' => 'noop', 'msg' => '已处理']; } // 步骤已推进 if ((int)$instance->current_step !== $idx) { - // return ['status' => 'noop', 'reason' => 'not_current_step', 'current_step' => (int)$instance->current_step]; - // throw new BusinessException("非当前步骤"); return ['status' => 'noop', 'msg' => '非当前审批']; } - // return ['status' => 'noop', 'reason' => 'no_pending_record']; return ['status' => 'noop', 'msg' => '无待处理记录']; } @@ -139,18 +135,22 @@ class ApprovalFlowService 'updated_at' => $now, ]); - // 异步生成pdf文件 9.11 add - if ($action === 'approve') { - \Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$instance->biz_id]); - } + // 异步审批回调 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, $idx, $type); // 'pending' | 'approve' | 'reject' + $outcome = $this->getStepOutcome($instanceId, $round, $idx, $type); // 'pending' | 'approve' | 'reject' if ($outcome === 'pending') { - // return ['status' => 'step_pending']; return ['status' => 'step_pending', 'msg' => '审批待定']; } @@ -165,8 +165,7 @@ class ApprovalFlowService 'updated_at' => $now, ]); $this->sendNotification([$instance->initiator_id], $instance, 'reject', false); - // return ['status' => 'completed', 'result' => 'reject']; - return ['status' => 'completed', 'msg' => '已拒绝']; + return ['status' => 'completed', 'msg' => '拒绝']; } // 5) 通过 => 进入下一步或结束 @@ -180,8 +179,7 @@ class ApprovalFlowService 'updated_at' => $now, ]); $this->sendNotification([$instance->initiator_id], $instance, 'approve', false); - // return ['status' => 'completed', 'result' => 'approve']; - return ['status' => 'completed', 'msg' => '已同意']; + return ['status' => 'completed', 'msg' => '同意']; } // 乐观推进 current_step @@ -191,7 +189,6 @@ class ApprovalFlowService ->where('status', 'processing') ->update(['current_step' => $nextIndex, 'updated_at' => $now]); if (!$affected) { - // return ['status' => 'noop', 'reason' => 'concurrent_progressed']; return ['status' => 'noop', 'msg' => '并发进展']; } @@ -199,12 +196,10 @@ class ApprovalFlowService $nextStep = $steps[$nextIndex]; $nextApprovers = $this->resolveApprovers($nextStep, $instance, true); foreach ($nextApprovers as $uid) { - $this->insertApprovalRecord($instanceId, $nextIndex, $uid); + $this->insertApprovalRecord($instance, $nextIndex, $uid); } $this->sendNotification($nextApprovers, $instance, 'pending', false); $this->notifyCCs($nextStep, $instance, 'pending'); - - // return ['status' => 'next_step', 'step_name' => $nextStep['name'] ?? ('Step '.$nextIndex)]; return ['status' => 'next_step', 'msg' => '进行下一步:'.$nextStep['name'] ?? ('Step '.$nextIndex)]; }); } @@ -253,6 +248,7 @@ class ApprovalFlowService $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'); @@ -325,6 +321,7 @@ class ApprovalFlowService 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]); @@ -482,19 +479,19 @@ class ApprovalFlowService * any:有 reject => reject;否则有 approve => approve;否则 pending * all:有 reject => reject;有 pending => pending;否则(全 approved 或 approved+skipped/timeout)=> approve */ - private function getStepOutcome(int $instanceId, int $stepIndex, string $type): string + 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 = 0; $approved = 0; $rejected = 0; foreach ($records as $r) { - if ($r->status === 'pending') { $pending++; } - elseif ($r->status === 'approved'){ $approved++; } - elseif ($r->status === 'rejected'){ $rejected++; } - // skipped/timeout 不影响聚合结论 + if ($r->status === 'pending') $pending++; + elseif ($r->status === 'approved') $approved++; + elseif ($r->status === 'rejected') $rejected++; } $type = strtolower($type); @@ -503,18 +500,18 @@ class ApprovalFlowService if ($approved > 0) return 'approve'; return 'pending'; } - // all if ($rejected > 0) return 'reject'; if ($pending > 0) return 'pending'; return $approved > 0 ? 'approve' : 'pending'; } /** 关闭本步其余待办为 skipped */ - private function closeOtherPending(int $instanceId, int $stepIndex, array $keepApproverIds = []): void + 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'); @@ -524,59 +521,32 @@ class ApprovalFlowService } /** 创建待办(去重) */ - private function insertApprovalRecord(int $instanceId, int $stepIndex, int $approverId): void + 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', $instanceId) + ->where('instance_id', (int)$instance->id) + ->where('round', $round) // ★ 限当前轮 ->where('step_index', $stepIndex) ->where('approver_id', $approverId) - ->count() > 0; - if (!$exists) { - Db::table('approval_records')->insert([ - 'instance_id' => $instanceId, - 'approver_id' => $approverId, - 'step_index' => $stepIndex, - 'action' => null, - 'status' => 'pending', - 'comment' => null, - 'approved_at' => null, - 'created_at' => $now, - 'updated_at' => $now, - ]); - } - } + ->where('status', 'pending') + ->exists(); - /** 发送通知(接你自己的消息系统即可) */ - private function sendNotification2(array $userIds, object $instance, string $action, bool $isCC): void - { - $map = [ - 'pending' => '待审批', - 'approve' => '已通过', - 'reject' => '已拒绝', - 'revoked' => '已撤回' - ]; - $prefix = $isCC ? '【抄送】' : '【审批】'; - $text = $map[$action] ?? $action; + if ($exists) return; - $approvalType = array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('approval_type')); - - switch ($instance->flow_type) { - case 'stock_orders': // 备货单 - $stockOrder = \app\common\model\StockOrders::find($instance->biz_id); - $title = $prefix."备货单:{$stockOrder->order_no}"; - $flowType = $approvalType[$instance->flow_type]; - $content = "{$prefix}你有一条审批 {$text}:类型 {$flowType},业务单号 {$stockOrder->order_no}"; - break; - case 'stock_orders_nullify': // 备货单作废 - break; - } - - noticePush([ - 'title' => $title, - 'content' => $content, - 'recipient' => $userIds, - 'email' => 1 + 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, ]); } @@ -815,100 +785,6 @@ class ApprovalFlowService ]; } - /** - * 极简:审批实例的时间轴(只返回每一步的总状态) - * - * @param int $instanceId - * @return array - */ - public function getSimpleTimeline1(int $instanceId): 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) ?: []; - $currentStep = (int)$instance->current_step; - - // 读出所有记录并分组 - $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(); - - $byStep = []; - foreach ($records as $r) { - $idx = (int)$r->step_index; - $byStep[$idx][] = $r; - } - - $timeline = []; - - 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'; - - if ($hasRecs) { - // 统计是否全部 timeout(且无 approve/reject/pending) - $cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0]; - foreach ($recs as $rr) { - if (isset($cnt[$rr->status])) $cnt[$rr->status]++; - } - - // 用已有的聚合规则拿到结论 - $outcome = $this->getStepOutcome($instanceId, $i, $type); // 'pending'|'approve'|'reject' - - if ($outcome === 'reject') { - $status = 'rejected'; - } elseif ($outcome === 'approve') { - $status = 'approved'; - } else { - // 没有结论,再看是不是纯超时 - if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) { - $status = 'timeout'; - } else { - $status = 'processing'; - } - } - } else { - // 无记录:根据位置与条件判断展示 - if ($i < $currentStep) { - $status = $condOk ? 'approved' : 'skipped'; - } elseif ($i === $currentStep && $instance->status === 'processing') { - // 理论上当前步应有记录;防御性展示为 processing - $status = 'processing'; - } else { - $status = $condOk ? 'pending' : 'skipped'; - } - } - - $timeline[] = [ - 'index' => (int)$i, - 'name' => $name, - 'type' => $type, // any | all - 'status' => $status, // approved | rejected | processing | pending | skipped | timeout - ]; - } - - return $timeline; - } - - /** * 极简时间轴(含每步时间 & 申请人信息) * @@ -944,6 +820,7 @@ class ApprovalFlowService $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; @@ -955,6 +832,7 @@ class ApprovalFlowService // 审批记录 $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.*') @@ -1027,7 +905,7 @@ class ApprovalFlowService $startedAt = $minCreated; // 总结果 - $outcome = $this->getStepOutcome($instanceId, $i, $type); // 'pending'|'approve'|'reject' + $outcome = $this->getStepOutcome($instanceId, $round, $i, $type); // 'pending'|'approve'|'reject' if ($outcome === 'reject') { $status = 'rejected'; $finishedAt = $minRejectAt; @@ -1135,6 +1013,7 @@ class ApprovalFlowService } $steps = json_decode($flow->steps, true) ?: []; $idx = (int)$instance->current_step; + $round = (int)$instance->round; $meta = $steps[$idx] ?? []; $typeRaw = strtolower($meta['type'] ?? 'all'); @@ -1148,6 +1027,7 @@ class ApprovalFlowService $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")) @@ -1156,6 +1036,7 @@ class ApprovalFlowService } else { $pendingRows = Db::table('approval_records') ->where('instance_id', $instance->id) + ->where('round', $round) ->where('step_index', $idx) ->where('status', 'pending') ->select('approver_id') @@ -1176,6 +1057,7 @@ class ApprovalFlowService // 先快速统计是否纯 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") @@ -1246,10 +1128,14 @@ class ApprovalFlowService ->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, @@ -1265,12 +1151,13 @@ class ApprovalFlowService '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($instanceId, $firstIndex, (int)$uid); + $this->insertApprovalRecord($instanceLite, $firstIndex, (int)$uid); } $extra = $appealText !== '' ? ['appeal' => $appealText] : []; @@ -1469,6 +1356,7 @@ class ApprovalFlowService // 锁定来源人的 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') @@ -1484,6 +1372,7 @@ class ApprovalFlowService // 若目标人在本步已有 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') @@ -1583,6 +1472,7 @@ class ApprovalFlowService // 锁定来源人的 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') @@ -1595,6 +1485,7 @@ class ApprovalFlowService // 若目标已有 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') diff --git a/app/common/utils/approval/service/ApprovalFlowService_2025092401.php b/app/common/utils/approval/service/ApprovalFlowService_2025092401.php new file mode 100644 index 0000000..40891c1 --- /dev/null +++ b/app/common/utils/approval/service/ApprovalFlowService_2025092401.php @@ -0,0 +1,1623 @@ +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, + '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, + ]; + + // 解析首步审批人(支持 role_id 或 roles.name) + $approverIds = $this->resolveApprovers($firstStep, $instance, true); + foreach ($approverIds as $uid) { + $this->insertApprovalRecord($instanceId, $firstIndex, $uid); + } + + // $this->sendNotification($approverIds, $instance, 'pending', false); + + $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'); + + 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; + $step = $steps[$idx] ?? null; + if (!$step) throw new BusinessException("当前步骤配置错误"); + + // 找到该用户在本步的 pending 记录(加锁) + $pendingRecord = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->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('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, + ]); + + // // 异步生成pdf文件 9.11 add + // if ($action === 'approve') { + // \Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$instance->biz_id]); + // } + // 异步审批回调 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, $idx, $type); // 'pending' | 'approve' | 'reject' + + if ($outcome === 'pending') { + return ['status' => 'step_pending', 'msg' => '审批待定']; + } + + // 3) 有结论:关闭本步其余 pending + $this->closeOtherPending($instanceId, $idx, [$approverId]); + + // 4) 拒绝 => 结束 + if ($outcome === 'reject') { + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'rejected', + 'finished_at' => $now, + 'updated_at' => $now, + ]); + $this->sendNotification([$instance->initiator_id], $instance, 'reject', false); + return ['status' => 'completed', 'msg' => '拒绝']; + } + + // 5) 通过 => 进入下一步或结束 + $formData = json_decode($instance->form_data, true) ?: []; + $nextIndex = $this->getNextStepIndex($steps, $formData, $idx); + + if ($nextIndex === null) { + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'approved', + 'finished_at' => $now, + 'updated_at' => $now, + ]); + $this->sendNotification([$instance->initiator_id], $instance, 'approve', false); + return ['status' => 'completed', 'msg' => '同意']; + } + + // 乐观推进 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($instanceId, $nextIndex, $uid); + } + $this->sendNotification($nextApprovers, $instance, 'pending', false); + $this->notifyCCs($nextStep, $instance, 'pending'); + return ['status' => 'next_step', 'msg' => '进行下一步:'.$nextStep['name'] ?? ('Step '.$nextIndex)]; + }); + } + + /** 撤回(仅发起人在无已处理记录前可撤回) */ + public function revoke(int $instanceId, int $initiatorId, string $reason = '') + { + return Db::transaction(function () use ($instanceId, $initiatorId, $reason) { + $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); + if (!$instance) throw new BusinessException('审批实例未找到'); + if ((int)$instance->initiator_id !== (int)$initiatorId) throw new BusinessException('无权撤回'); + if ($instance->status !== 'processing') throw new BusinessException('流程已结束或不可撤回'); + + $handled = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->whereIn('status', ['approved','rejected','timeout','skipped']) + ->count() > 0; + if ($handled) throw new BusinessException('当前进度不可撤回'); + + $now = date('Y-m-d H:i:s'); + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'revoked', + 'finished_at' => $now, + 'updated_at' => $now, + ]); + + 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); + 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) + ->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'; + $finalAction = ($flow->timeout_action === 'auto_approve') ? 'approve' : 'reject'; + + Db::table('approval_instances')->where('id', $instance->id)->update([ + 'status' => $final, + 'finished_at' => $now, + 'updated_at' => $now, + ]); + + Db::table('approval_records') + ->where('instance_id', $instance->id) + ->where('step_index', $instance->current_step) + ->where('status', 'pending') + ->update(['status' => 'timeout', 'approved_at' => $now, 'updated_at' => $now]); + + $this->notifyInitiator($instance, $finalAction); + }); + } + } + + // ---------- 内部工具 ---------- + + /** 解析审批人(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; + 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; + 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; + } + + /** + * 聚合本步骤结论(按类型) + * 返回:'pending' | 'approve' | 'reject' + * + * any:有 reject => reject;否则有 approve => approve;否则 pending + * all:有 reject => reject;有 pending => pending;否则(全 approved 或 approved+skipped/timeout)=> approve + */ + private function getStepOutcome(int $instanceId, int $stepIndex, string $type): string + { + $records = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('step_index', $stepIndex) + ->get(); + + $pending = 0; $approved = 0; $rejected = 0; + foreach ($records as $r) { + if ($r->status === 'pending') { $pending++; } + elseif ($r->status === 'approved'){ $approved++; } + elseif ($r->status === 'rejected'){ $rejected++; } + // skipped/timeout 不影响聚合结论 + } + + $type = strtolower($type); + if ($type === 'any') { + if ($rejected > 0) return 'reject'; + if ($approved > 0) return 'approve'; + return 'pending'; + } + // all + if ($rejected > 0) return 'reject'; + if ($pending > 0) return 'pending'; + return $approved > 0 ? 'approve' : 'pending'; + } + + /** 关闭本步其余待办为 skipped */ + private function closeOtherPending(int $instanceId, int $stepIndex, array $keepApproverIds = []): void + { + $now = date('Y-m-d H:i:s'); + $q = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->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(int $instanceId, int $stepIndex, int $approverId): void + { + $now = date('Y-m-d H:i:s'); + $exists = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('step_index', $stepIndex) + ->where('approver_id', $approverId) + ->count() > 0; + if (!$exists) { + Db::table('approval_records')->insert([ + 'instance_id' => $instanceId, + 'approver_id' => $approverId, + 'step_index' => $stepIndex, + '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' => '已撤回', + ]; + $prefix = $isCC ? '【抄送】' : '【审批】'; + $text = $map[$action] ?? $action; + + // 业务标题/内容 + $approvalType = array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('approval_type')); + + 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': + // 备货单作废 + 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, + * records: array, + * 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, + ]; + } + + /** + * 极简:审批实例的时间轴(只返回每一步的总状态) + * + * @param int $instanceId + * @return array + */ + public function getSimpleTimeline1(int $instanceId): 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) ?: []; + $currentStep = (int)$instance->current_step; + + // 读出所有记录并分组 + $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(); + + $byStep = []; + foreach ($records as $r) { + $idx = (int)$r->step_index; + $byStep[$idx][] = $r; + } + + $timeline = []; + + 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'; + + if ($hasRecs) { + // 统计是否全部 timeout(且无 approve/reject/pending) + $cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0]; + foreach ($recs as $rr) { + if (isset($cnt[$rr->status])) $cnt[$rr->status]++; + } + + // 用已有的聚合规则拿到结论 + $outcome = $this->getStepOutcome($instanceId, $i, $type); // 'pending'|'approve'|'reject' + + if ($outcome === 'reject') { + $status = 'rejected'; + } elseif ($outcome === 'approve') { + $status = 'approved'; + } else { + // 没有结论,再看是不是纯超时 + if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) { + $status = 'timeout'; + } else { + $status = 'processing'; + } + } + } else { + // 无记录:根据位置与条件判断展示 + if ($i < $currentStep) { + $status = $condOk ? 'approved' : 'skipped'; + } elseif ($i === $currentStep && $instance->status === 'processing') { + // 理论上当前步应有记录;防御性展示为 processing + $status = 'processing'; + } else { + $status = $condOk ? 'pending' : 'skipped'; + } + } + + $timeline[] = [ + 'index' => (int)$i, + 'name' => $name, + 'type' => $type, // any | all + 'status' => $status, // approved | rejected | processing | pending | skipped | timeout + ]; + } + + return $timeline; + } + + + /** + * 极简时间轴(含每步时间 & 申请人信息) + * + * @param int $instanceId + * @param string|null $userNameColumn 申请人姓名列(如 'name' / 'nickname')。不需要就传 null + * @param string $initiatorPlacement 'prepend' | 'attach' + * - 'prepend':在最前插入一条“发起申请”块(推荐) + * - 'attach' :把申请人信息并到第一步的返回项里(不新增块) + * + * 返回:按显示顺序的数组,每项: + * { + * index:int // 步骤索引;若为“发起申请”块则为 -1 + * name:string // 步骤名或“发起申请” + * type:string // any|all 或 '-'(发起块) + * status:string // approved|rejected|processing|pending|skipped|timeout|submitted(发起块) + * started_at:?string + * finished_at:?string + * duration_seconds:?int + * // 仅发起块或 attach 时的第一步会包含: + * initiator_id?:int + * initiator_name?:?string + * } + */ + 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; + + // 申请人 + $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) + ->orderBy('ar.step_index') + ->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc') + ->select('ar.*') + ->get() + ->toArray(); + + $byStep = []; + foreach ($records as $r) { + $idx = (int)$r->step_index; + $byStep[$idx][] = $r; + } + + $timeline = []; + + // 可选:先插入“发起申请”块(index = -1) + 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; + + 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) { + 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]++; + } + + $startedAt = $minCreated; + + // 总结果 + $outcome = $this->getStepOutcome($instanceId, $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, + ]; + + // 如果需要把申请人信息“并到第一步”而不是独立一块 + 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 + * } + * } + */ + 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; + $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.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('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('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); + } + + // 清理可能遗留的 pending + Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('status', 'pending') + ->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]); + + // 重新开启实例 + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'processing', + 'current_step' => $firstIndex, + '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, + ]; + $approvers = $this->resolveApprovers($firstStep, $instanceLite, true); + if (empty($approvers)) throw new BusinessException('重提后首步未解析到审批人'); + + foreach ($approvers as $uid) { + $this->insertApprovalRecord($instanceId, $firstIndex, (int)$uid); + } + + $extra = $appealText !== '' ? ['appeal' => $appealText] : []; + $this->sendNotification($approvers, $instanceLite, 'pending', false, $extra); + $this->notifyCCs($firstStep, $instanceLite, 'pending'); + + 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); + } + + // ====== ====== ====== ====== ====== ====== 转交操作 ====== ====== ====== ====== ====== ====== + + /** + * 转交当前待办给他人 + * 仅允许【当前这条待办的审批人】把自己名下且为 pending 的记录转交给 toUserId。 + * - 若目标人在本步已有 pending:合并(把本记录标记为 skipped,保留目标人的那条) + * - 否则:原地把该记录的 approver_id 改为 toUserId + * - 写入转交日志 + * - 通知被转交人 + */ + public function transferApproval2(int $instanceId, int $stepIndex, int $fromUserId, int $toUserId, string $reason = ''): array + { + if ($fromUserId === $toUserId) { + return ['status' => 'noop', 'msg' => '目标与来源相同,无需转交']; + } + + return Db::transaction(function () use ($instanceId, $stepIndex, $fromUserId, $toUserId, $reason) { + // 锁实例,避免并发推进 + $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('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('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; + } + + // 写转交日志 + 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, + ]); + + // 通知被转交人(沿用你的 sendNotification) + $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); + + return ['status' => 'ok', 'record_id' => $effectiveRecordId]; + }); + } + + /** + * 查看某个实例的转交流水(按时间升序) + */ + 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('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('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, + ]); + + return ['status' => 'ok', 'record_id' => $effectiveRecordId]; + }); + } + +} \ No newline at end of file diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php index 8dd01f6..fb87763 100644 --- a/app/controller/IndexController.php +++ b/app/controller/IndexController.php @@ -101,8 +101,10 @@ class IndexController public function test(Request $request) { - $service = new \app\crm\service\StockOrdersService(); - $result = $service->createdPdfFile(35); + + $info = \app\common\model\PurchaseOrders::find(14); + $service = new \app\common\service\PurchaseOrdersService(); + $result = $service->startApproval($info); return json($result); } diff --git a/app/functions.php b/app/functions.php index 7ae0d0a..21e53c9 100644 --- a/app/functions.php +++ b/app/functions.php @@ -119,7 +119,8 @@ if (!function_exists('flg')) { if ($isFs) { $data = [ 'content' => $eventData, - 'time' => date('Y-m-d H:i:s', time()) + 'time' => date('Y-m-d H:i:s', time()), + 'source' => getenv('ENVIRONMENT') ]; RedisQueueRedis::send("server", $data); } diff --git a/app/queue/redis/ApprovalCallback.php b/app/queue/redis/ApprovalCallback.php new file mode 100644 index 0000000..58c0a1f --- /dev/null +++ b/app/queue/redis/ApprovalCallback.php @@ -0,0 +1,82 @@ + $this->approvalStockOrders($data), + 'purchase_orders' => $this->approvalPurchaseOrders($data), + default => throw new BusinessException('未知的审批类型,无法执行回调操作') + }; + return true; + } catch (\Throwable $th) { + flg('审批回调异常:'.$th->getFile()."|".$th->getLine()."|".$th->getMessage(), false); + throw new BusinessException($th->getFile()."|".$th->getLine()."|".$th->getMessage()); + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } + + /** + * 备货单处理 + */ + private function approvalStockOrders($data) + { + // 生成PDF文件 + $service = new \app\crm\service\StockOrdersService(); + $result = $service->createdPdfFile($data['biz_id']); + return $result; + } + + /** + * 采购单处理 + */ + private function approvalPurchaseOrders($data) + { + $udata['id'] = $data['biz_id']; + if ($data['status'] === 'approve') { + $udata['status'] = 2; + } + + if ($data['status'] === 'reject') { + $udata['status'] = 3; + } + + $service = new \app\common\service\PurchaseOrdersService(); + $result = $service->updateStatus($udata); + return $result; + } +} \ No newline at end of file diff --git a/app/queue/redis/Server.php b/app/queue/redis/Server.php index d425ea7..2d9814e 100644 --- a/app/queue/redis/Server.php +++ b/app/queue/redis/Server.php @@ -19,10 +19,12 @@ class Server implements Consumer public function consume($data) { try { + $source = $data['source'] === 'development' ? '-- 开发环境 --' : '** 生产环境 **'; $webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/806c2e9d-6ca2-4345-a459-69bb2651706b'; $message = "====== ======\n"; $message .= $data['content']."\n"; $message .= "时间:".date('Y-m-d H:i:s', time())."\n"; + $message .= "来源:".$source."\n"; $message .= "====== ======"; sendFeishuTextMessage($webhookUrl, $message); return true;