From 57ecb30d3f0d119274bb1a2deeb269f93aafc0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= Date: Mon, 10 Aug 2026 17:28:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=AE=A1=E6=89=B9=EF=BC=8C?= =?UTF-8?q?=E5=8A=A0=E7=8F=AD=EF=BC=8C=E5=A4=96=E5=87=BA=EF=BC=8C=E8=AF=B7?= =?UTF-8?q?=E5=81=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../models/soong_workflow_batch_approval.py | 337 ++++++++++++++---- .../soong_workflow_batch_approval_views.xml | 4 +- 2 files changed, 266 insertions(+), 75 deletions(-) diff --git a/soong_workflow/models/soong_workflow_batch_approval.py b/soong_workflow/models/soong_workflow_batch_approval.py index 1f538138f..5671c1a11 100644 --- a/soong_workflow/models/soong_workflow_batch_approval.py +++ b/soong_workflow/models/soong_workflow_batch_approval.py @@ -1,24 +1,52 @@ # -*- coding: utf-8 -*- -# 一键审批(批量):复用现有 soong_workflow 引擎无交互推进,不动 mixin/record 现有方法 +# 一键审批(批量):复用业务模型自身的 btn_exec_action(正常审批入口)无交互推进, +# 不动 mixin / record / 各业务模型的现有方法 from odoo import models from odoo.exceptions import UserError +class _RollbackForPreview(Exception): + """仅用于 _preview_next_approvers:在 savepoint 内强制回滚预测的副作用, + 不让 _add_comment 对 trans.link 等临时写操作落库。""" + + +class BatchApproveManualRequired(UserError): + """下一节点有多个审批人,需人工手动审批(批量一键审批时跳过该条,不自动批) + + 携带结构化信息用于在结果通知里展示:流水号 / 名称 / 下一节点 / 多审核人 + """ + def __init__(self, message, code=None, name=None, next_node=None, approvers=None): + super().__init__(message) + self.code = code + self.name = name + self.next_node = next_node + self.approvers = approvers + + +# 允许一键审批的流程类型白名单(名字需与 soong.workflow 的 name 完全一致) +# 增删类型直接改这个列表即可,其余逻辑无需改动 +BATCH_APPROVAL_WORKFLOWS = ['加班', '请假', '外出'] + + class SoongWorkflowRecord(models.Model): _inherit = 'soong.workflow.record' def action_batch_approve(self): - """批量一键审批:对选中的“我的待办”记录,按当前节点自动执行 确认+转交/结束(方案A:只批当前登录人所在级并推进)""" + """批量一键审批:对选中的“我的待办”记录,按当前节点自动执行 确认→转交/结束 + (方案A:只批当前登录人所在级并推进;仅处理白名单内的流程类型) + """ current_user = self.env.user success_records = self.env['soong.workflow.record'] failed_info = [] # 收集失败原因,保证单条失败不影响其余 + manual_info = [] # 需人工审批(下一节点多人)的记录,跳过不自动批 for rec in self: - # 限制:只处理审批类型为"加班"的工作流记录 - if rec.workflow_id.name != '加班': - failed_info.append('记录%s:审批类型【%s】不是加班,已跳过' % (rec.id, rec.workflow_id.name or '空')) + # 限制:只处理白名单内的审批类型 + if rec.workflow_id.name not in BATCH_APPROVAL_WORKFLOWS: + failed_info.append('记录%s:审批类型【%s】不在一键审批范围内(仅支持%s),已跳过' % ( + rec.id, rec.workflow_id.name or '空', '/'.join(BATCH_APPROVAL_WORKFLOWS))) continue try: - # 取到对应的业务记录(如 overtime.approval) + # 取到对应的业务记录(如 overtime.approval / hr.leave.batch / go.approval) biz = self.env[rec.res_model].browse(rec.res_id).exists() if not biz: failed_info.append('记录%s:业务记录不存在' % rec.id) @@ -39,28 +67,63 @@ class SoongWorkflowRecord(models.Model): if not pending_log: failed_info.append('记录%s:当前用户不是该节点待办人' % rec.id) continue - # 取当前节点上当前登录人可见的 确认/转交/结束 按钮 + # 取当前节点上可见的“审批类”按钮,按角色归类(确认/转交/结束),排除退回/驳回/收回 visible_btns = self._get_visible_action_buttons(node, biz) - # 先执行“确认”(若挂了该按钮):标记当前节点审毕,不推进 - if visible_btns.get('确认'): - self._auto_exec_engine(biz, visible_btns['确认'], node, is_transfer=False) - # 再判定推进动作:末节点“结束”办结,中间节点“转交”推进 - if visible_btns.get('结束'): - self._auto_exec_engine(biz, visible_btns['结束'], node, is_transfer=False) - elif visible_btns.get('转交'): - self._auto_exec_engine(biz, visible_btns['转交'], node, is_transfer=True) - else: - # 既无结束也无转交:若连确认都没有,则本节点无可执行动作 - if not visible_btns.get('确认'): - failed_info.append('记录%s:当前节点无可见的确认/转交/结束按钮' % rec.id) - continue + if not any(visible_btns.get(k) for k in ('confirm', 'transfer', 'end')): + failed_info.append('记录%s:当前节点无可执行的审批按钮(确认/转交/结束)' % rec.id) + continue + + # 关键顺序:先预测转交/结束是否需要人工(下一节点多人),确认阶段不执行任何写操作。 + # 若预测发现需人工,则整条记录跳过,当前节点状态保持原样(不会变成“已办结”)。 + transfer_preview = None + if visible_btns.get('end'): + # 结束节点直接放行:不需要再选下一节点审批人 + pass + elif visible_btns.get('transfer'): + transfer_preview = self._validate_transfer_or_skip(biz, visible_btns['transfer'], node, rec) + + # 预测通过后,按正常顺序执行:确认 -> 结束/转交 + if visible_btns.get('confirm'): + self._auto_exec_engine(biz, visible_btns['confirm'], node, is_transfer=False, rec=rec) + if visible_btns.get('end'): + self._auto_exec_engine(biz, visible_btns['end'], node, is_transfer=False, rec=rec) + elif visible_btns.get('transfer'): + self._auto_exec_engine(biz, visible_btns['transfer'], node, is_transfer=True, rec=rec, preview=transfer_preview) success_records += rec + except BatchApproveManualRequired as e: + # 下一节点有多位审批人:收集信息并跳过该条,不影响其余记录 + manual_info.append({ + 'code': e.code or rec.code or ('记录%s' % rec.id), + 'next_node': e.next_node or '', + 'approvers': '、'.join(e.approvers.mapped('name')) if e.approvers else '', + }) except Exception as e: failed_info.append('记录%s:%s' % (rec.id, str(e))) # 汇总结果,用通知展示 - if failed_info: - message = '一键审批完成:成功 %s 条,失败 %s 条。\n失败明细:\n%s' % ( - len(success_records), len(failed_info), '\n'.join(failed_info)) + if failed_info or manual_info: + parts = [] + if success_records: + parts.append('成功 %s 条' % len(success_records)) + if manual_info: + parts.append('需人工 %s 条(已跳过)' % len(manual_info)) + if failed_info: + parts.append('失败 %s 条' % len(failed_info)) + message = '一键审批完成:%s' % ','.join(parts) + # 需人工的记录:按原因分组,相同原因只写一遍,只列流水号 + if manual_info: + grouped = {} + for m in manual_info: + key = (m['next_node'], m['approvers']) + grouped.setdefault(key, []).append(m['code']) + lines = [] + for (next_node, approvers), codes in grouped.items(): + lines.append('下一节点【%s】有多个审批人(%s),请人工手动审批:' % (next_node, approvers)) + for code in codes: + lines.append('· %s' % code) + lines.append('') + message += '\n\n%s' % '\n'.join(lines).rstrip() + if failed_info: + message += '\n\n失败明细:\n%s' % '\n'.join(failed_info) notify_type = 'warning' else: message = '一键审批完成:成功 %s 条,全部通过。' % len(success_records) @@ -75,12 +138,13 @@ class SoongWorkflowRecord(models.Model): 'sticky': True, } } + def action_batch_approve_all(self): - """移动端 kanban 一键审批:处理当前用户所有【待办+加班】审批,不依赖勾选/当前视图""" + """移动端 kanban 一键审批:处理当前用户所有【待办 + 白名单内】审批,不依赖勾选/当前视图""" current_user = self.env.user - # 自行搜索当前用户的待办加班审批,避免依赖勾选或当前视图记录集 + # 自行搜索当前用户的待办(白名单内)审批,避免依赖勾选或当前视图记录集 records = self.search([ - ('workflow_id.name', '=', '加班'), + ('workflow_id.name', 'in', BATCH_APPROVAL_WORKFLOWS), ('is_to_do', '=', True), ('users_ids', 'in', [current_user.id]), ]) @@ -90,25 +154,60 @@ class SoongWorkflowRecord(models.Model): 'tag': 'display_notification', 'params': { 'title': '一键审批', - 'message': '当前没有待审批的加班流程。', + 'message': '当前没有待审批的(%s)流程。' % '/'.join(BATCH_APPROVAL_WORKFLOWS), 'type': 'info', 'sticky': False, } } - # 复用逐条审批逻辑(含当前用户必须是节点待办人的前置校验) + # 复用逐条审批逻辑(含当前用户必须是节点待办人的前置校验 + 白名单过滤) return records.action_batch_approve() + def _classify_button(self, btn): + """把节点按钮归类为审批流程中的角色: + 'confirm'(确认/登记,不推进) / 'transfer'(转交/推进) / 'end'(结束/办结) / + 'reject'(退回/驳回/收回,绝不自动点) / 'skip'(加签等其它,忽略) + 优先按引擎标准字段 action_type / review_type 判定,避免依赖中文名 + """ + at = btn.action_type + rt = btn.review_type + # 驳回 / 收回:绝不自动点 + if at == 'link_action_refuse' or rt == 'withdraw': + return 'reject' + # 加签:忽略 + if at == 'apostille': + return 'skip' + if at == 'end' or rt == 'end': + return 'end' + if at == 'confirm' or rt == 'confirm': + return 'confirm' + if at in ('link', 'link_start_statu', 'link_action', 'link_action_approve'): + return 'transfer' + # 兜底:按中文名匹配(覆盖未配置 action_type 的情况) + name = (btn.name or '').strip() + if name in ('结束', '办结'): + return 'end' + if name in ('确认', '同意', '通过'): + return 'confirm' + if name in ('转交', '提交', '送审'): + return 'transfer' + if name in ('退回', '驳回', '拒绝', '收回'): + return 'reject' + return 'skip' + def _get_visible_action_buttons(self, node, biz): - """取当前节点上当前登录人可见的 确认/转交/结束 按钮,返回 {按钮名: 按钮对象}""" + """取当前节点上当前登录人可见的“审批类”按钮,按角色返回 {角色: 按钮对象} + 同一角色只保留第一个可见按钮(正常流程也只会有一个有效按钮) + """ result = {} - action_names = ('确认', '转交', '结束') for btn in node.button_ids.sorted('sequence'): - if btn.name not in action_names: + role = self._classify_button(btn) + if role in ('reject', 'skip'): continue # 按引擎规则判断按钮可见性 if not self._is_button_visible(btn, biz): continue - result[btn.name] = btn + if role not in result: + result[role] = btn return result def _is_button_visible(self, btn, biz): @@ -121,15 +220,40 @@ class SoongWorkflowRecord(models.Model): domain = eval(btn.invisible_expression) return bool(biz.search(domain + [('id', '=', biz.id)])) - def _auto_exec_engine(self, biz, btn_rec, node, is_transfer): - """复用现有引擎无交互执行某个按钮:先校验、再更新 log、最后 add_comment,避免失败时部分办结""" - # 1. 先调 _add_comment 仅计算 wizard 字段(不更新 log,仅取 context;会 unlink trans.link,属可接受副作用) - # _add_comment 内部不使用传入的 logs,这里用当前节点最新 log 占位 - log_placeholder = self.env['soong.workflow.log'].search([ - ('model', '=', biz._name), - ('res_id', '=', biz.id), - ('node_id', '=', btn_rec.node_id.id), - ], order='lase_click_date DESC', limit=1) + def _get_common_note_id(self): + """取当前用户常用意见第一条(与 wizard 的 comment_note_id 下拉 domain 一致)""" + user = self.env.user + user_role_ids = self.env['soong.user.role'] + for emp in user.employee_ids: + user_role_ids |= emp.user_role_ids + if emp.user_role_id: + user_role_ids |= emp.user_role_id + if not user_role_ids: + return False + note = self.env['soong.note'].search([ + ('note_setting_id.users_role_ids', 'in', user_role_ids.ids) + ], order='id', limit=1) + return note.id if note else False + + def _preview_next_approvers(self, biz, btn_rec): + """预测转交/结束按钮的下一节点及审批人。 + + 因为 _add_comment 内部会写 trans.link(先删后建),为了不污染数据库, + 整个预测过程用 savepoint 包裹,并在取到结果后主动抛 _RollbackForPreview, + 让 savepoint 回滚所有副作用。调用方负责捕获 _RollbackForPreview 并从返回值读取结果。 + + 返回字典: + { + 'trans_link_id': int, # 下一节点 trans.link id(转交/结束) + 'trans_link_name': str, # 下一节点名称 + 'approvers': recordset, # 下一节点审批人 recordset(可能为空) + 'next_node_id': int, # 下一节点 soong.workflow.node id + 'approval_concatenation': str, # 下一节点并/串审模式 + } + """ + result = {'trans_link_id': False, 'trans_link_name': '', 'approvers': self.env['hr.employee'], + 'next_node_id': False, 'approval_concatenation': False} + cx = self.env.context.copy() cx.update({ 'active_id': biz.id, @@ -137,9 +261,85 @@ class SoongWorkflowRecord(models.Model): 'active_model': biz._name, 'btn_key': btn_rec.btn_key, }) - action = biz.with_context(cx)._add_comment(log_placeholder, btn_rec) + log_placeholder = self.env['soong.workflow.log'].search([ + ('model', '=', biz._name), + ('res_id', '=', biz.id), + ('node_id', '=', btn_rec.node_id.id), + ], order='lase_click_date DESC', limit=1) + + try: + with self.env.cr.savepoint(): + pred_action = biz.with_context(cx)._add_comment(log_placeholder, btn_rec) + pred_ctx = pred_action.get('context', {}) or {} + trans_link_id = pred_ctx.get('default_trans_link_id') + if trans_link_id: + trans_link = self.env['trans.link'].browse(trans_link_id) + approvers = biz._get_approvers_for_link(trans_link, biz) + next_node = biz.workflow_id.node_ids.search([ + ('name', '=', trans_link.name), + ('workflow_id', '=', biz.workflow_id.id), + ], limit=1) + result.update({ + 'trans_link_id': trans_link_id, + 'trans_link_name': trans_link.name, + 'approvers': approvers, + 'next_node_id': next_node.id, + 'approval_concatenation': next_node.approval_concatenation if next_node else False, + }) + # 关键:主动抛内部异常,让 savepoint 回滚 _add_comment 的副作用 + raise _RollbackForPreview() + except _RollbackForPreview: + pass + return result + + def _validate_transfer_or_skip(self, biz, btn_rec, node, rec): + """转交前校验:若下一节点存在多位审批人,则抛出 BatchApproveManualRequired, + 让调用方跳过该条且不执行任何写操作(当前节点不会变成已办结)。 + """ + preview = self._preview_next_approvers(biz, btn_rec) + trans_link_id = preview['trans_link_id'] + if not trans_link_id: + raise UserError('当前节点【%s】无法计算出有效下一节点(后续节点按钮均不可见或路径为空)' % node.name) + approvers = preview['approvers'] + if not approvers: + raise UserError('下一节点【%s】未匹配到审批人,请检查节点配置' % preview['trans_link_name']) + if len(approvers) > 1: + raise BatchApproveManualRequired( + '下一节点【%s】有 %s 位审批人(%s),系统无法自动唯一确定,请人工手动审批' % ( + preview['trans_link_name'], len(approvers), '、'.join(approvers.mapped('name'))), + code=rec.code if rec else None, + name=rec.name if rec else None, + next_node=preview['trans_link_name'], + approvers=approvers, + ) + return preview + + def _auto_exec_engine(self, biz, btn_rec, node, is_transfer, rec=None, preview=None): + """复用业务模型自身的 btn_exec_action(正常审批入口)无交互推进: + - 调用 biz.btn_exec_action(btn_key) —— 等价于界面上点这个按钮: + · go.approval 等子类的 btn_exec_action 校验会自然执行(如转交时间段冲突) + · mixin 的 btn_exec_action 会跑 _create_update_logs + _add_comment,返回审批向导 + - 从返回的向导上下文取出字段,构造向导并执行 add_comment(绕过 UI 弹窗) + · add_comment 内部会触发 node_btn_id.run(),从而调用各模型的后置业务方法 + (overtime.put_hr_employee / go.end_approval / 请假的处理逻辑等) + 这样一键审批与正常走流程触发的方法完全一致。 + + 参数 preview:当 is_transfer=True 时,传入 _validate_transfer_or_skip 的返回值, + 避免重复调用 _add_comment(已确认下一节点为单人)。 + """ + cx = self.env.context.copy() + cx.update({ + 'active_id': biz.id, + 'active_ids': biz.ids, + 'active_model': biz._name, + 'btn_key': btn_rec.btn_key, + }) + + action = biz.with_context(cx).btn_exec_action() + if not action or action.get('type') != 'ir.actions.act_window': + raise UserError('节点【%s】按钮【%s】未返回审批向导,无法一键审批' % (node.name, btn_rec.name)) + ctx = action.get('context', {}) or {} - # 2. 构造 wizard 基础字段,并对转交做前置校验(在更新当前节点 log 之前,防止部分办结) wizard_vals = { 'res_model': ctx.get('default_res_model') or biz._name, 'res_id': ctx.get('default_res_id') or biz.id, @@ -148,40 +348,31 @@ class SoongWorkflowRecord(models.Model): 'workflow_record_id': ctx.get('default_workflow_record_id'), } if is_transfer: - trans_link_id = ctx.get('default_trans_link_id') + if preview and preview.get('trans_link_id'): + trans_link_id = preview['trans_link_id'] + approvers = preview['approvers'] + approval_concatenation = preview.get('approval_concatenation') + else: + trans_link_id = ctx.get('default_trans_link_id') + trans_link = self.env['trans.link'].browse(trans_link_id) + approvers = biz._get_approvers_for_link(trans_link, biz) + next_node = biz.workflow_id.node_ids.search([ + ('name', '=', trans_link.name), + ('workflow_id', '=', biz.workflow_id.id), + ], limit=1) + approval_concatenation = next_node.approval_concatenation if next_node else False if not trans_link_id: - raise UserError('当前节点【%s】无法计算出有效下一节点(后续节点按钮均不可见或路径为空)' % node.name) - trans_link = self.env['trans.link'].browse(trans_link_id) - approvers = biz._get_approvers_for_link(trans_link, biz) + raise UserError('当前节点【%s】无法计算出有效下一节点' % node.name) if not approvers: - raise UserError('下一节点【%s】未匹配到审批人,请检查节点配置' % trans_link.name) - # 下一节点所有审批人都选 + raise UserError('下一节点未匹配到审批人,请检查节点配置') wizard_vals['trans_link_id'] = trans_link_id wizard_vals['employee_ids'] = [(6, 0, approvers.ids)] - # 关键:按下一节点的并/串审模式设置,否则 add_comment 默认走串审,破坏并审逻辑 - next_node = biz.workflow_id.node_ids.search([ - ('name', '=', trans_link.name), - ('workflow_id', '=', biz.workflow_id.id), - ], limit=1) - if next_node: - wizard_vals['approval_concatenation'] = next_node.approval_concatenation - # 取当前用户常用意见第一条作为审批意见(与 wizard 的 comment_note_id 下拉 domain 一致) - user = self.env.user - user_role_ids = self.env['soong.user.role'] - for emp in user.employee_ids: - user_role_ids |= emp.user_role_ids - if emp.user_role_id: - user_role_ids |= emp.user_role_id - comment_note_id = False - if user_role_ids: - note = self.env['soong.note'].search([ - ('note_setting_id.users_role_ids', 'in', user_role_ids.ids) - ], order='id', limit=1) - comment_note_id = note.id if note else False - wizard_vals['comment_note_id'] = comment_note_id - # 3. 校验通过后再更新/创建当前节点 log(与 btn_exec_action 一致) - log_obj = biz._create_update_logs(btn_rec) - # 4. 构造 wizard 并直接执行推进(绕过 UI 弹窗) + if approval_concatenation: + wizard_vals['approval_concatenation'] = approval_concatenation + + wizard_vals['comment_note_id'] = self._get_common_note_id() + + # 以业务记录身份创建并执行向导(绕过 UI 弹窗) # 关键:必须把 active_model/active_id 指向业务记录,否则节点按钮的服务器动作会把 obj 当成 soong.workflow.record wizard = self.env['soong.workflow.comment.wizard'].with_context( active_model=biz._name, diff --git a/soong_workflow/views/soong_workflow_batch_approval_views.xml b/soong_workflow/views/soong_workflow_batch_approval_views.xml index 21f01ff7e..c604f60f4 100644 --- a/soong_workflow/views/soong_workflow_batch_approval_views.xml +++ b/soong_workflow/views/soong_workflow_batch_approval_views.xml @@ -10,13 +10,13 @@