diff --git a/app/common/service/InvItemService.php b/app/common/service/InvItemService.php index 05bbe84..43a1f44 100644 --- a/app/common/service/InvItemService.php +++ b/app/common/service/InvItemService.php @@ -6,6 +6,7 @@ use app\common\validate\InvItemValidate; use app\common\traits\crud\CrudServiceTrait; use app\common\model\InvItem; use support\Request; +use support\exception\BusinessException; class InvItemService { @@ -62,4 +63,76 @@ class InvItemService $data['location'] = ''; return $data; } + + + /** + * 变动物品库存数量 + * + * @param int $itemId 物品ID + * @param int $changeQty 变动数量:>0 入库,<0 出库 + * @param bool $allowNegative 是否允许库存变成负数,默认不允许 + * + * @return InvItem 最新的物品记录 + * @throws BusinessException + */ + public function changeStock(int $itemId, int $changeQty, bool $allowNegative = false): InvItem + { + if ($changeQty === 0) { + throw new BusinessException('库存变动数量不能为 0'); + } + + $now = time(); + + // 构建查询 + $query = InvItem::query()->where('id', $itemId); + + // 如果不允许负数,并且是出库场景(变动为负) + if (! $allowNegative && $changeQty < 0) { + // 要保证:当前 stock_qty >= 出库数量 + $query->where('stock_qty', '>=', -$changeQty); + } + + // 使用 increment 实现:UPDATE inv_item SET stock_qty = stock_qty + ?, updated_at = ? WHERE ... + $affected = $query->increment('stock_qty', $changeQty, [ + 'updated_at' => $now, + ]); + + if ($affected === 0) { + // 更新不到行: + // 1) 物品不存在 + // 2) 或者库存不足(不允许负数时) + $exists = InvItem::query()->where('id', $itemId)->exists(); + if (! $exists) { + throw new BusinessException('物品不存在'); + } + throw new BusinessException('库存不足,无法完成库存变动'); + } + + // 再查一次最新数据返回(在外层事务中也会保持一致) + /** @var InvItem $item */ + $item = InvItem::query()->find($itemId); + return $item; + } + + /** + * 出库(减库存,默认不允许负数) + */ + public function stockOut(int $itemId, int $qty, bool $allowNegative = false): InvItem + { + if ($qty <= 0) { + throw new BusinessException('出库数量必须大于 0'); + } + return $this->changeStock($itemId, -$qty, $allowNegative); + } + + /** + * 入库(加库存) + */ + public function stockIn(int $itemId, int $qty): InvItem + { + if ($qty <= 0) { + throw new BusinessException('入库数量必须大于 0'); + } + return $this->changeStock($itemId, $qty, true); + } } \ No newline at end of file diff --git a/app/common/service/InvRequisitionItemReturnService.php b/app/common/service/InvRequisitionItemReturnService.php index 5955a77..a90f199 100644 --- a/app/common/service/InvRequisitionItemReturnService.php +++ b/app/common/service/InvRequisitionItemReturnService.php @@ -2,6 +2,7 @@ namespace app\common\service; +use app\common\model\InvRequisition; use app\common\model\InvRequisitionItem; use app\common\validate\InvRequisitionItemReturnValidate; use app\common\traits\crud\CrudServiceTrait; @@ -268,30 +269,181 @@ class InvRequisitionItemReturnService InvRequisitionItemReturnAttachment::where('return_id', $returnId)->delete(); } - - - public function returnConfirm(Request $request, array $data, string $scene = '') + /** + * 物品领用归还确认 + * + * @param Request $request + * @param array $data + * @param string $scene + * @return array|bool + * @throws BusinessException + */ + public function serviceReturnConfirm(Request $request, array $data, string $scene = 'returnConfirm') { try { - - // 关联表增加事务处理 - return Db::transaction(function () use ($request, $data) { - $data = $this->preProcess('Update', $data, $request); - $config = $this->buildQueryConfig($request, 'update'); + // 归还确认是业务顶层方法,这里统一使用事务 + return Db::transaction(function () use ($request, $data, $scene) { - $attachment = $data['attachment']; - unset($data['attachment']); + // 1. 当前操作人信息 + $userInfo = (new UserService())->getUserInfo($request->user->uid, ''); + if (empty($userInfo['user']['id']) || empty($userInfo['user']['name'])) { + throw new BusinessException('无法获取当前用户信息'); + } - $this->logicUpdateBy($config, $data); - // 获取更新后的完整记录 - $updated = $this->logicShow($config); - // 处理附件更新 - $this->processRequisitionItemReturnAttachment($updated, $attachment, true); - return $this->postProcess('Update', $updated); + $handlerId = (int)$userInfo['user']['id']; + $handlerName = (string)$userInfo['user']['name']; + + $data['handler_id'] = $handlerId; + $data['handler_name'] = $handlerName; + + // 确认状态:1=通过,2=拒绝 (按你现在用法) + $status = isset($data['status']) ? (int)$data['status'] : null; + if (!in_array($status, [1, 2], true)) { + throw new BusinessException('非法的归还处理状态'); + } + + // 2. 构建查询配置,定位到这条归还记录 + $config = $this->buildQueryConfig($request, $scene); + + // 更新时不允许前端覆盖主键 + unset($data['requisition_item_return_id']); + + // 3. 获取现有记录 + /** @var \app\model\InvRequisitionItemReturn|null $existingRecord */ + $existingRecord = $this->logicShow($config); + + if (!$existingRecord) { + throw new BusinessException('未找到对应的归还申请记录'); + } + + // 已处理校验:只要归还记录的 status 已经是「通过或拒绝」,就不能再处理 + $existingStatus = (int)($existingRecord->status ?? 0); + if (in_array($existingStatus, [1, 2], true) || !empty($existingRecord->handler_id)) { + throw new BusinessException('该归还申请已经被处理过了'); + } + + // 4. 更新归还记录(状态 + 审核人 + 备注等) + $updateResult = $this->logicUpdateBy($config, $data); + if ($updateResult === 0) { + throw new BusinessException('更新归还申请失败,可能记录不存在或已被处理'); + } + + // 5. 若是拒绝,直接返回(不改库存、不改已归还数量) + if ($status === 2) { + // 拒绝:这里你返回 true 或者返回更新后的记录都可以 + return true; + } + + // 6. 审核通过:继续处理领用明细 & 库存 & 领用单状态 + // 注意:requisition_item_id、item_id、return_qty 等字段在本次确认流程中不会被修改, + // 所以可以直接使用 $existingRecord 中的值(都是只读字段) + $requisitionItemId = (int)$existingRecord->requisition_item_id; + $itemId = (int)$existingRecord->item_id; + $returnQty = (int)$existingRecord->return_qty; + $requisitionId = (int)$existingRecord->requisition_id; + + if ($requisitionItemId <= 0 || $itemId <= 0 || $returnQty <= 0 || $requisitionId <= 0) { + throw new BusinessException('归还申请记录缺少必要字段,无法处理'); + } + + // 6.1 处理领用明细 + /** @var \app\model\InvRequisitionItem|null $item */ + $item = InvRequisitionItem::find($requisitionItemId); + if (!$item) { + throw new BusinessException('未找到对应的领用明细记录'); + } + + $approvedQty = (int)($item->approved_qty ?? 0); + $returnedQty = (int)($item->returned_qty ?? 0); + + if ($approvedQty <= 0) { + throw new BusinessException('该物品尚未审批出库,无法归还'); + } + + if ($returnedQty + $returnQty > $approvedQty) { + throw new BusinessException('归还数量超过已借数量,无法确认'); + } + + $item->returned_qty = $returnedQty + $returnQty; + if ($item->returned_qty >= $approvedQty) { + $item->is_finished = 1; + } + $item->updated_at = time(); + + if ($item->save() === false) { + throw new BusinessException('更新领用明细失败'); + } + + // 6.2 处理库存(入库) + (new InvItemService())->stockIn($itemId, $returnQty); + + // 6.3 检查整张单是否已全部归还(只看非耗材) + $notFinishedCount = InvRequisitionItem::query() + ->where('requisition_id', $requisitionId) + ->where('is_consumable_snap', 0) + ->where('is_finished', 0) + ->count(); + + if ($notFinishedCount === 0) { + // 全部非耗材都已归还 + $affected = InvRequisition::where('id', $requisitionId)->update([ + 'is_returned' => 1, + 'updated_at' => time(), + ]); + + // Eloquent::update 返回的是影响行数,0 表示未更新到任何行 + if ($affected === 0) { + throw new BusinessException('更新领用单状态失败,可能记录不存在'); + } + } + + // 7. 返回处理结果(保持你原来的 postProcess 习惯) + return $this->postProcess(ucfirst($scene), $existingRecord); }); - + + } catch (BusinessException $e) { + throw $e; } catch (\Throwable $e) { throw new BusinessException("确认归还物品失败:" . $e->getMessage()); } } + + /** + * 构建查询配置 + * + * @param Request $request + * @param string $scene + * @return array + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'returnConfirm': + $config['where']['id'] = $p['requisition_item_return_id']; + break; + case 'index': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['where']['status'] = 1; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } } \ No newline at end of file diff --git a/app/common/service/InvRequisitionService.php b/app/common/service/InvRequisitionService.php index dfff880..6c4e8e2 100644 --- a/app/common/service/InvRequisitionService.php +++ b/app/common/service/InvRequisitionService.php @@ -47,9 +47,13 @@ class InvRequisitionService { $user = (new UserService())->getUserInfo($request->user->uid, ''); + if (empty($user['user']['id']) || empty($user['user']['name'])) { + throw new BusinessException('无法获取当前用户信息'); + } + $data['order_no'] = $this->generateOrderNo(); - $data['applicant_id'] = $user['user']['id']; - $data['applicant_name'] = $user['user']['name']; + $data['applicant_id'] = (int)$user['user']['id']; + $data['applicant_name'] = (string)$user['user']['name']; $data['leader_id'] = $user['user']['report_to']; $data['status'] = InvRequisition::STATUS_LEADER; diff --git a/app/common/utils/approval/service/ApprovalFlowService.php b/app/common/utils/approval/service/ApprovalFlowService.php index a26b79a..6b39023 100644 --- a/app/common/utils/approval/service/ApprovalFlowService.php +++ b/app/common/utils/approval/service/ApprovalFlowService.php @@ -7,1947 +7,10 @@ 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, - * 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, - ]; - } - - - 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 - * } - * } - */ - 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()); - } - } -} \ No newline at end of file + use traits\ApprovalEngineTrait; + use traits\ApprovalQueryTrait; + use traits\ApprovalResolverTrait; + use traits\ApprovalNotificationTrait; + use traits\ApprovalDiscussionTrait; + use traits\ApprovalTransferTrait; +} diff --git a/app/common/utils/approval/service/ApprovalFlowService_20251208.php b/app/common/utils/approval/service/ApprovalFlowService_20251208.php new file mode 100644 index 0000000..989bf7c --- /dev/null +++ b/app/common/utils/approval/service/ApprovalFlowService_20251208.php @@ -0,0 +1,1953 @@ +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, + * 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, + ]; + } + + + 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 + * } + * } + */ + 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()); + } + } +} \ No newline at end of file diff --git a/app/common/utils/approval/traits/ApprovalDiscussionTrait.php b/app/common/utils/approval/traits/ApprovalDiscussionTrait.php new file mode 100644 index 0000000..3a74940 --- /dev/null +++ b/app/common/utils/approval/traits/ApprovalDiscussionTrait.php @@ -0,0 +1,151 @@ +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; + } + + /** 发表讨论 */ + 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, + ]; + } + + /** 获取讨论列表 */ + 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); + } +} diff --git a/app/common/utils/approval/traits/ApprovalEngineTrait.php b/app/common/utils/approval/traits/ApprovalEngineTrait.php new file mode 100644 index 0000000..b1fa986 --- /dev/null +++ b/app/common/utils/approval/traits/ApprovalEngineTrait.php @@ -0,0 +1,592 @@ +where('type', $flowType)->where('is_active', 1)->first(); + if (!$flow) throw new BusinessException("审批流程未找到或已停用: {$flowType}"); + + $steps = json_decode($flow->steps, true); + if (!is_array($steps) || empty($steps)) throw new BusinessException("流程步骤配置错误"); + + // 找首个有效步骤索引(考虑条件) + $firstIndex = $this->getNextStepIndex($steps, $formData, -1); + if ($firstIndex === null) throw new BusinessException("未找到可执行的审批步骤"); + $firstStep = $steps[$firstIndex]; + + $now = date('Y-m-d H:i:s'); + $deadlineAt = null; + if (($flow->timeout_hours ?? 0) > 0) { + $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); + } + + $instanceId = Db::table('approval_instances')->insertGetId([ + 'flow_type' => $flowType, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => $initiatorId, + 'status' => 'processing', + 'current_step' => $firstIndex, + 'round' => 1, + 'deadline_at' => $deadlineAt, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $instance = (object)[ + 'id' => $instanceId, + 'flow_type' => $flowType, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => $initiatorId, + 'current_step' => $firstIndex, + 'status' => 'processing', + 'flow_type' => $flowType, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'round' => 1, + ]; + + // 解析首步审批人(支持 role_id 或 roles.name) + $approverIds = $this->resolveApprovers($firstStep, $instance, true); + foreach ($approverIds as $uid) { + $this->insertApprovalRecord($instance, $firstIndex, $uid); + } + + $extra = []; + $submitNote = $formData['submit_note'] ?? ''; + if (is_string($submitNote) && $submitNote !== '') { + $extra['note'] = $submitNote; + } + $this->sendNotification($approverIds, $instance, 'pending', false, $extra); + + // $this->notifyCCs($firstStep, $instance, 'pending'); + + $this->pushTodoChanged($approverIds); + + return ['instance_id' => $instanceId]; + }); + } + + /** 执行审批(幂等友好) */ + public function approve(int $instanceId, int $approverId, string $action, string $comment = '') + { + if (!in_array($action, ['approve','reject'], true)) { + throw new BusinessException("无效的审批操作"); + } + + return Db::transaction(function () use ($instanceId, $approverId, $action, $comment) { + $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); + if (!$instance) throw new BusinessException("审批实例未找到"); + if ($instance->status !== 'processing') { + return ['status' => 'noop', 'msg' => '实例未处理']; + } + + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) throw new BusinessException("审批流程定义未找到"); + + $steps = json_decode($flow->steps, true); + $idx = (int)$instance->current_step; + $round = (int)$instance->round; + $step = $steps[$idx] ?? null; + if (!$step) throw new BusinessException("当前步骤配置错误"); + + // 找到该用户在本步的 pending 记录(加锁) + $pendingRecord = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', $round) + ->where('approver_id', $approverId) + ->where('step_index', $idx) + ->where('status', 'pending') + ->lockForUpdate() + ->first(); + + if (!$pendingRecord) { + // 已处理过? + $anyRecord = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', $round) + ->where('approver_id', $approverId) + ->where('step_index', $idx) + ->first(); + if ($anyRecord && $anyRecord->status !== 'pending') { + return ['status' => 'noop', 'msg' => '已处理']; + } + // 步骤已推进 + if ((int)$instance->current_step !== $idx) { + return ['status' => 'noop', 'msg' => '非当前审批']; + } + return ['status' => 'noop', 'msg' => '无待处理记录']; + } + + $now = date('Y-m-d H:i:s'); + + // 1) 更新当前人的审批记录 + Db::table('approval_records')->where('id', $pendingRecord->id)->update([ + 'action' => $action, + 'status' => ($action === 'approve') ? 'approved' : 'rejected', + 'comment' => $comment, + 'approved_at' => $now, + 'updated_at' => $now, + ]); + + // 异步审批回调 9.23 add + $approvalcallback = [ + 'biz_id' => $instance->biz_id, + 'flow_type' => $instance->flow_type, + 'instance_id' => $instance->id, + 'initiator_id' => $instance->initiator_id, + 'status' => $action + ]; + \Webman\RedisQueue\Redis::send('approval-callback', $approvalcallback); + + // 2) 聚合本步结论(any / all) + $typeRaw = strtolower($step['type'] ?? 'all'); + $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; + $outcome = $this->getStepOutcome($instanceId, $round, $idx, $type); // 'pending' | 'approve' | 'reject' + + if ($outcome === 'pending') { + $this->pushTodoChanged([$approverId]); + return ['status' => 'step_pending', 'msg' => '审批待定']; + } + + $otherPendingUids = $this->getPendingApproverIds($instanceId, $round, $idx); + + // 3) 有结论:关闭本步其余 pending + $this->closeOtherPending($instanceId, $round, $idx, [$approverId]); + + // 这些人 pending→skipped,待办数减少 + $this->pushTodoChanged($otherPendingUids); + + // 4) 拒绝 => 结束 + if ($outcome === 'reject') { + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'rejected', + 'finished_at' => $now, + 'updated_at' => $now, + ]); + $this->onFlowFinalized($instance, 'rejected'); + $this->sendNotification([$instance->initiator_id], $instance, 'reject', false); + $this->pushTodoChanged([$approverId]); + return ['status' => 'completed', 'msg' => '拒绝']; + } + + // 5) 通过 => 进入下一步或结束 + $formData = json_decode($instance->form_data, true) ?: []; + $nextIndex = $this->getNextStepIndex($steps, $formData, $idx); + + if ($nextIndex === null) { + $finalStatus = $this->normalizeFinalStatus((string)$instance->flow_type, 'approved'); + $notifyAction = ($finalStatus === 'voided') ? 'voided' : 'approve'; + + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => $finalStatus, + 'finished_at' => $now, + 'updated_at' => $now, + ]); + $this->onFlowFinalized($instance, $finalStatus); + + // 新增:在流程最终完成时才发本步骤的抄送 + if (!empty($step['cc'])) { + $this->notifyCCs($step, $instance, $notifyAction); + } + + $this->sendNotification([$instance->initiator_id], $instance, $notifyAction, false); + $this->pushTodoChanged([$approverId]); + return ['status' => 'completed', 'msg' => ($finalStatus === 'voided' ? '已作废' : '同意')]; + } + + // 乐观推进 current_step + $affected = Db::table('approval_instances') + ->where('id', $instanceId) + ->where('current_step', $idx) + ->where('status', 'processing') + ->update(['current_step' => $nextIndex, 'updated_at' => $now]); + if (!$affected) { + return ['status' => 'noop', 'msg' => '并发进展']; + } + + // 生成下一步待办 + 通知 + $nextStep = $steps[$nextIndex]; + $nextApprovers = $this->resolveApprovers($nextStep, $instance, true); + foreach ($nextApprovers as $uid) { + $this->insertApprovalRecord($instance, $nextIndex, $uid); + } + $this->sendNotification($nextApprovers, $instance, 'pending', false); + // $this->notifyCCs($nextStep, $instance, 'pending'); + $this->pushTodoChanged(array_merge([$approverId], $nextApprovers)); + return ['status' => 'next_step', 'msg' => '进行下一步:'.$nextStep['name'] ?? ('Step '.$nextIndex)]; + }); + } + + /** 撤回(仅发起人在无已处理记录前可撤回) */ + public function revoke(int $instanceId, int $initiatorId, string $reason = '') + { + return Db::transaction(function () use ($instanceId, $initiatorId, $reason) { + $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); + if (!$instance) throw new BusinessException('审批实例未找到'); + if ((int)$instance->initiator_id !== (int)$initiatorId) throw new BusinessException('无权撤回'); + if ($instance->status !== 'processing') throw new BusinessException('流程已结束或不可撤回'); + + $handled = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->whereIn('status', ['approved','rejected','timeout','skipped']) + ->count() > 0; + if ($handled) throw new BusinessException('当前进度不可撤回'); + + $now = date('Y-m-d H:i:s'); + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'revoked', + 'finished_at' => $now, + 'updated_at' => $now, + ]); + + $this->onFlowFinalized($instance, 'revoked'); + + $pendingUids = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('status', 'pending') + ->pluck('approver_id')->toArray(); + + Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('status', 'pending') + ->update(['status' => 'skipped', 'approved_at' => $now, 'updated_at' => $now]); + + $this->sendNotification([$initiatorId], $instance, 'revoked', false); + $this->pushTodoChanged($pendingUids); + return ['status' => 'completed', 'result' => 'revoked']; + }); + } + + /** 定时任务:处理超时(实例级) */ + public function handleTimeouts() + { + $now = date('Y-m-d H:i:s'); + $expired = Db::table('approval_instances') + ->where('status', 'processing') + ->whereNotNull('deadline_at') + ->where('deadline_at', '<=', $now) + ->get(); + + foreach ($expired as $instance) { + Db::transaction(function () use ($instance, $now) { + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) return; + + $final = ($flow->timeout_action === 'auto_approve') ? 'approved' : 'rejected'; + $final = $this->normalizeFinalStatus((string)$instance->flow_type, $final); + $finalAction = ($final === 'voided') ? 'voided' : (($final === 'approved') ? 'approve' : 'reject'); + + Db::table('approval_instances')->where('id', $instance->id)->update([ + 'status' => $final, + 'finished_at' => $now, + 'updated_at' => $now, + ]); + + $this->onFlowFinalized((object)$instance, $final); + + $pendingUids = Db::table('approval_records') + ->where('instance_id', $instance->id) + ->where('round', (int)$instance->round) + ->where('step_index', $instance->current_step) + ->where('status', 'pending') + ->pluck('approver_id')->toArray(); + + Db::table('approval_records') + ->where('instance_id', $instance->id) + ->where('round', (int)$instance->round) + ->where('step_index', $instance->current_step) + ->where('status', 'pending') + ->update(['status' => 'timeout', 'approved_at' => $now, 'updated_at' => $now]); + + $this->notifyInitiator($instance, $finalAction); + + $this->pushTodoChanged($pendingUids); + }); + } + } + + /** 重提交(仅对 status=rejected 的实例;支持申诉说明) */ + public function resubmit(int $instanceId, int $initiatorId, array $patchData = [], string $appealText = ''): array + { + return Db::transaction(function () use ($instanceId, $initiatorId, $patchData, $appealText) { + $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); + if (!$instance) throw new BusinessException('审批实例未找到'); + if ((int)$instance->initiator_id !== (int)$initiatorId) throw new BusinessException('仅发起人可重提'); + if ($instance->status !== 'rejected') throw new BusinessException('仅被驳回的实例可重提'); + + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) throw new BusinessException('审批流程定义未找到'); + + $steps = json_decode($flow->steps, true) ?: []; + $formData = json_decode($instance->form_data ?? '{}', true) ?: []; + + // 合并改动 & 打上重提标记 + $formData = array_merge($formData, $patchData); + $formData['resubmitted'] = 1; + + $appealText = trim((string)$appealText); + if ($appealText !== '') { + $formData['appeal_last'] = $appealText; + } + + // 计算重提后首个命中的步骤 + $firstIndex = $this->getNextStepIndex($steps, $formData, -1); + if ($firstIndex === null) throw new BusinessException('重提后未找到可执行的步骤'); + + $now = date('Y-m-d H:i:s'); + $deadlineAt = null; + if (($flow->timeout_hours ?? 0) > 0) { + $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); + } + + $oldPendingUids = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('status', 'pending') + ->pluck('approver_id')->toArray(); + + // 清理可能遗留的 pending + Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('status', 'pending') + ->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]); + + // 轮次 +1 + $newRound = ((int)$instance->round) + 1; + + // 重新开启实例 + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'processing', + 'current_step' => $firstIndex, + 'round' => $newRound, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'finished_at' => null, + 'deadline_at' => $deadlineAt, + 'updated_at' => $now, + ]); + + // 创建待办 + 通知 + $firstStep = $steps[$firstIndex]; + $instanceLite = (object)[ + 'id' => (int)$instanceId, + 'flow_type' => (string)$instance->flow_type, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => (int)$instance->initiator_id, + 'biz_type' => (string)$instance->biz_type, + 'biz_id' => (string)$instance->biz_id, + 'round' => $newRound, + ]; + $approvers = $this->resolveApprovers($firstStep, $instanceLite, true); + if (empty($approvers)) throw new BusinessException('重提后首步未解析到审批人'); + + foreach ($approvers as $uid) { + $this->insertApprovalRecord($instanceLite, $firstIndex, (int)$uid); + } + + $extra = $appealText !== '' ? ['appeal' => $appealText] : []; + $this->sendNotification($approvers, $instanceLite, 'pending', false, $extra); + $this->notifyCCs($firstStep, $instanceLite, 'pending'); + $this->pushTodoChanged(array_merge($oldPendingUids, $approvers)); + + return [ + 'status' => 'resubmitted', + 'instance_id' => (int)$instanceId, + 'current_step' => $firstIndex, + 'step_name' => $firstStep['name'] ?? ('Step '.$firstIndex), + ]; + }); + } + + /** 新增:发起“作废审批” */ + public function startNullify( + string $voidFlowType, + string $bizType, + string $bizId, + array $formData, + int $initiatorId + ): array { + return Db::transaction(function () use ($voidFlowType, $bizType, $bizId, $formData, $initiatorId) { + // 1) 流程定义校验 + $flow = Db::table('approval_flows')->where('type', $voidFlowType)->where('is_active', 1)->first(); + if (!$flow) throw new BusinessException("作废流程未找到或已停用: {$voidFlowType}"); + $steps = json_decode($flow->steps, true); + if (!is_array($steps) || empty($steps)) throw new BusinessException("作废流程步骤配置错误"); + + // 2) 必须存在“已通过”的最新有效实例(非作废流程) + $latestApproved = Db::table('approval_instances') + ->where('biz_type', $bizType) + ->where('biz_id', $bizId) + ->where('flow_type', '!=', $voidFlowType) + ->where('status', 'approved') + ->orderByDesc('id') + ->first(); + + if (!$latestApproved) { + throw new BusinessException('仅已通过的单据可发起作废'); + } + + // 3) 不允许并行的作废流程 + $voidProcessing = Db::table('approval_instances') + ->where('flow_type', $voidFlowType) + ->where('biz_id', $bizId) + ->where('status', 'processing') + ->exists(); + if ($voidProcessing) { + throw new BusinessException('已存在进行中的作废审批'); + } + + // A. 基于历史“作废流程已通过”的实例拦截 + $voidApproved = Db::table('approval_instances') + ->where('flow_type', $voidFlowType) + ->where('biz_id', $bizId) + ->whereIn('status', ['approved','voided']) + ->exists(); + if ($voidApproved) { + throw new BusinessException('该单据已作废,无需重复作废'); + } + + // B.(可选)直接查业务表状态拦截(若你用 approval_status=4 表示已作废) + /* + $orderVoided = \app\common\model\StockOrders::where('id', $bizId) + ->where('approval_status', 4) + ->exists(); + if ($orderVoided) { + throw new BusinessException('该单据已作废,无需重复作废'); + } + */ + + // 4) 计算首步 + // 注:作废流程可以有独立条件;若不需要条件,直接第一步 + $formData = array_merge([ + '_op' => 'nullify', + '_nullify_reason' => $formData['nullify_reason'] ?? ($formData['reason'] ?? ''), + '_nullify_of_instance_id' => (int)$latestApproved->id, + ], $formData); + + $firstIndex = $this->getNextStepIndex($steps, $formData, -1); + if ($firstIndex === null) throw new BusinessException("作废流程未命中任何审批步骤"); + $firstStep = $steps[$firstIndex]; + + $now = date('Y-m-d H:i:s'); + $deadlineAt = null; + if (($flow->timeout_hours ?? 0) > 0) { + $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); + } + + // 5) 新建作废实例(round 固定从 1 开始) + $instanceId = Db::table('approval_instances')->insertGetId([ + 'flow_type' => $voidFlowType, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => $initiatorId, + 'status' => 'processing', + 'current_step' => $firstIndex, + 'round' => 1, // + 'deadline_at' => $deadlineAt, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $instance = (object)[ + 'id' => $instanceId, + 'flow_type' => $voidFlowType, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => $initiatorId, + 'current_step' => $firstIndex, + 'status' => 'processing', + 'round' => 1, // + ]; + + // 6) 生成首步待办 + 通知(带上作废原因) + $approverIds = $this->resolveApprovers($firstStep, $instance, true); + foreach ($approverIds as $uid) { + $this->insertApprovalRecord($instance, $firstIndex, $uid); + } + $extra = []; + if (!empty($formData['_nullify_reason'])) { + $extra['note'] = '【作废原因】'.$formData['_nullify_reason']; + } + $this->sendNotification($approverIds, $instance, 'pending', false, $extra); + $this->notifyCCs($firstStep, $instance, 'pending'); + $this->pushTodoChanged($approverIds); + return ['instance_id' => $instanceId]; + }); + } + + // 流程完结后的业务回调(approved|rejected|revoked|timeout?) + private function onFlowFinalized(object $instance, string $final): void + { + try { + switch ($instance->flow_type) { + case 'stock_orders': { + $order = \app\common\model\StockOrders::find($instance->biz_id); + if (!$order) { + throw new BusinessException('订单不存在'); + } + if ($final === 'approved') { + $order->approval_status = 2; + $order->save(); + } elseif ($final === 'rejected') { + $order->approval_status = 3; + $order->save(); + } elseif ($final === 'revoked') { + $order->approval_status = 0; + $order->save(); + } + break; + } + case 'stock_orders_nullify': + if ($final === 'approved') { + $final = 'voided'; + Db::table('approval_instances')->where('id', $instance->id)->update([ + 'status' => 'voided', + ]); + } + if (in_array($final, ['voided','approved'], true)) { + $order = \app\common\model\StockOrders::find($instance->biz_id); + if ($order && $order->approval_status !== 4) { + $order->approval_status = 4; + $order->save(); + } + } + break; + default: + // 非作废流程,无需处理 + break; + } + } catch (\Throwable $e) { + Log::error('onFlowFinalized error: '.$e->getMessage()); + } + } + + // 是否作废流程 + private function isNullifyFlow(string $flowType): bool + { + $t = strtolower(trim($flowType)); + return str_ends_with($t, '_nullify') || in_array($t, [ + // 所有“作废”类流程的类型 + 'stock_orders_nullify', + ], true); + } + + // 归一化最终状态 + private function normalizeFinalStatus(string $flowType, string $final): string + { + if ($this->isNullifyFlow($flowType) && $final === 'approved') { + return 'voided'; + } + return $final; + } +} diff --git a/app/common/utils/approval/traits/ApprovalNotificationTrait.php b/app/common/utils/approval/traits/ApprovalNotificationTrait.php new file mode 100644 index 0000000..4ad1f6b --- /dev/null +++ b/app/common/utils/approval/traits/ApprovalNotificationTrait.php @@ -0,0 +1,155 @@ + '待审批', + '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); + } + + /** 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()); + } + } + + /** WebSocket:把一组用户的“待办数量”推送给前端(统一口径) */ + 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()); + } + } +} diff --git a/app/common/utils/approval/traits/ApprovalQueryTrait.php b/app/common/utils/approval/traits/ApprovalQueryTrait.php new file mode 100644 index 0000000..2f8e443 --- /dev/null +++ b/app/common/utils/approval/traits/ApprovalQueryTrait.php @@ -0,0 +1,475 @@ +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 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获取“最新进展”的极简状态 */ + 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; + } + + /** 登录/首屏初始化:获取当前用户的待办总数 */ + 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); + } +} diff --git a/app/common/utils/approval/traits/ApprovalResolverTrait.php b/app/common/utils/approval/traits/ApprovalResolverTrait.php new file mode 100644 index 0000000..b6b3868 --- /dev/null +++ b/app/common/utils/approval/traits/ApprovalResolverTrait.php @@ -0,0 +1,406 @@ +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}"), + }; + } + + /** 获取下一个有效步骤索引 */ + 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, + ]); + } + + /** 取“某实例、某轮次、某步骤”里当前 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))); + } + + /** 从角色取用户ID */ + 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; + } + + /** 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; + } +} diff --git a/app/common/utils/approval/traits/ApprovalTransferTrait.php b/app/common/utils/approval/traits/ApprovalTransferTrait.php new file mode 100644 index 0000000..8c00e21 --- /dev/null +++ b/app/common/utils/approval/traits/ApprovalTransferTrait.php @@ -0,0 +1,130 @@ +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); + } + + /** 转交当前待办给他人 */ + public function transferApproval( + int $instanceId, + int $stepIndex, + int $fromUserId, + int $toUserId, + string $reason = '', + ?string $userNameColumn = null + ): 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]; + }); + } +} diff --git a/app/mobile/controller/InvRequisitionItemReturnController.php b/app/mobile/controller/InvRequisitionItemReturnController.php index 4a6ebbe..58df807 100644 --- a/app/mobile/controller/InvRequisitionItemReturnController.php +++ b/app/mobile/controller/InvRequisitionItemReturnController.php @@ -46,7 +46,7 @@ class InvRequisitionItemReturnController extends BaseController $data = $this->validate($this->service->getValidator(), $request->post(), 'returnConfirm'); // 处理申请逻辑 - $result = $this->service->returnConfirm($request, $data, 'returnConfirm'); + $result = $this->service->serviceReturnConfirm($request, $data, 'returnConfirm'); return $this->success($result, '申请提交成功'); } catch (\Throwable $e) { diff --git a/config/route.php b/config/route.php index 54f6cce..a93349d 100644 --- a/config/route.php +++ b/config/route.php @@ -646,5 +646,6 @@ Route::group('/mobile', function () { Route::any('/requisition/update', [app\mobile\controller\InvRequisitionController::class, 'update']); Route::any('/requisition/item/return', [app\mobile\controller\InvRequisitionItemReturnController::class, 'store']); Route::any('/requisition/item/return/update', [app\mobile\controller\InvRequisitionItemReturnController::class, 'update']); + Route::any('/requisition/item/return/confirm', [app\mobile\controller\InvRequisitionItemReturnController::class, 'returnConfirm']); }); }); \ No newline at end of file diff --git a/public/db_log/审批.sql b/public/db_log/审批.sql new file mode 100644 index 0000000..42e65da --- /dev/null +++ b/public/db_log/审批.sql @@ -0,0 +1,94 @@ +CREATE TABLE `approval_flows` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL COMMENT '流程名称', + `type` varchar(100) NOT NULL COMMENT '业务类型标识,如 leave, reimbursement', + `steps` json NOT NULL COMMENT '审批步骤配置', + `is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `timeout_hours` int(11) NOT NULL DEFAULT '0' COMMENT '实例级超时时间(小时),0为不超时', + `timeout_action` varchar(20) NOT NULL DEFAULT 'auto_reject' COMMENT 'auto_approve | auto_reject', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flow_type` (`type`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COMMENT='审批流程定义'; + +CREATE TABLE `approval_instances` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `flow_type` varchar(100) NOT NULL COMMENT '关联 approval_flows.type', + `biz_id` varchar(255) NOT NULL COMMENT '业务ID', + `biz_type` varchar(100) NOT NULL COMMENT '业务标识/表名', + `form_data` json NOT NULL COMMENT '提交表单快照', + `initiator_id` bigint(20) unsigned NOT NULL COMMENT '发起人', + `status` varchar(20) NOT NULL DEFAULT 'processing' COMMENT 'processing|approved|rejected|revoked|timed_out', + `current_step` int(11) NOT NULL DEFAULT '0' COMMENT '当前步骤索引', + `round` int(11) NOT NULL DEFAULT '1' COMMENT '当前轮次:1=首次,重提+1', + `finished_at` timestamp NULL DEFAULT NULL, + `deadline_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_flow_type` (`flow_type`), + KEY `idx_biz` (`biz_type`,`biz_id`), + KEY `idx_initiator` (`initiator_id`), + KEY `idx_status_updated` (`status`,`updated_at`) +) ENGINE=InnoDB AUTO_INCREMENT=68 DEFAULT CHARSET=utf8mb4 COMMENT='审批实例'; + +CREATE TABLE `approval_records` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `instance_id` bigint(20) unsigned NOT NULL COMMENT 'approval_instances.id', + `approver_id` bigint(20) unsigned NOT NULL COMMENT '审批人', + `step_index` int(11) NOT NULL COMMENT '步骤索引', + `round` int(11) NOT NULL DEFAULT '1' COMMENT '所属轮次', + `action` varchar(20) DEFAULT NULL COMMENT 'approve|reject,待办时为NULL', + `status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending|approved|rejected|skipped|timeout', + `comment` text COMMENT '审批意见', + `approved_at` timestamp NULL DEFAULT NULL COMMENT '审批时间/关闭时间', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_instance_step_approver` (`instance_id`,`step_index`,`approver_id`), + KEY `idx_instance_step_status` (`instance_id`,`step_index`,`status`), + KEY `idx_approver` (`approver_id`), + KEY `idx_instance_step_round` (`instance_id`,`step_index`,`round`) +) ENGINE=InnoDB AUTO_INCREMENT=105 DEFAULT CHARSET=utf8mb4 COMMENT='审批记录'; + +CREATE TABLE `approval_transfers` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `instance_id` bigint(20) unsigned NOT NULL, + `step_index` int(11) NOT NULL, + `record_id` bigint(20) unsigned NOT NULL COMMENT '转交后仍然有效的记录ID(若合并则为目标记录ID)', + `from_user_id` bigint(20) unsigned NOT NULL, + `to_user_id` bigint(20) unsigned NOT NULL, + `reason` text, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_record` (`record_id`), + KEY `idx_instance_step` (`instance_id`,`step_index`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批转交日志'; + +CREATE TABLE `approval_ccs` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `instance_id` bigint(20) unsigned NOT NULL, + `user_id` bigint(20) unsigned NOT NULL, + `notified_at` timestamp NULL DEFAULT NULL, + `read_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_instance_user` (`instance_id`,`user_id`), + KEY `idx_user` (`user_id`), + KEY `idx_instance` (`instance_id`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COMMENT='审批抄送'; + +CREATE TABLE `approval_discussions` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `instance_id` bigint(20) unsigned NOT NULL, + `user_id` bigint(20) unsigned NOT NULL, + `is_internal` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=公开,1=仅审批人可见', + `content` text NOT NULL, + `attachments` json DEFAULT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_instance_id` (`instance_id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='审批讨论'; \ No newline at end of file