# -*- coding: utf-8 -*- # 一键审批(批量):复用业务模型自身的 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:只批当前登录人所在级并推进;仅处理白名单内的流程类型) """ 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 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 / 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) continue # 当前节点:优先用 record 上的 node_id,回退到业务记录 state 对应的节点 node = rec.node_id or (biz.workflow_id.node_ids.filtered(lambda n: n.node_name == biz.state)[:1] if biz.workflow_id else False) if not node: failed_info.append('记录%s:当前节点为空' % rec.id) continue # 前置校验:当前登录人必须是当前节点的待办人(有待办/办理中的 log) pending_log = biz.log_ids.filtered( lambda l: l.model == biz._name and l.res_id == biz.id and l.node_id.id == node.id and l.done_by.id == current_user.id and l.approval_state in ('0', '1') ) if not pending_log: failed_info.append('记录%s:当前用户不是该节点待办人' % rec.id) continue # 取当前节点上可见的“审批类”按钮,按角色归类(确认/转交/结束),排除退回/驳回/收回 visible_btns = self._get_visible_action_buttons(node, biz) 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 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) notify_type = 'success' return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': '一键审批结果', 'message': message, 'type': notify_type, 'sticky': True, } } def action_batch_approve_all(self): """移动端 kanban 一键审批:处理当前用户所有【待办 + 白名单内】审批,不依赖勾选/当前视图""" current_user = self.env.user # 自行搜索当前用户的待办(白名单内)审批,避免依赖勾选或当前视图记录集 records = self.search([ ('workflow_id.name', 'in', BATCH_APPROVAL_WORKFLOWS), ('is_to_do', '=', True), ('users_ids', 'in', [current_user.id]), ]) if not records: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': '一键审批', '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 = {} for btn in node.button_ids.sorted('sequence'): role = self._classify_button(btn) if role in ('reject', 'skip'): continue # 按引擎规则判断按钮可见性 if not self._is_button_visible(btn, biz): continue if role not in result: result[role] = btn return result def _is_button_visible(self, btn, biz): """判断按钮对当前记录是否可见(复刻引擎的 is_review_invisible + invisible_expression 规则)""" if not btn.is_review_invisible: return True if not btn.invisible_expression: return True # 与引擎一致:把当前记录 id 拼进 domain 判定是否命中 domain = eval(btn.invisible_expression) return bool(biz.search(domain + [('id', '=', biz.id)])) 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, 'active_ids': biz.ids, 'active_model': biz._name, 'btn_key': btn_rec.btn_key, }) 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 {} wizard_vals = { 'res_model': ctx.get('default_res_model') or biz._name, 'res_id': ctx.get('default_res_id') or biz.id, 'code': ctx.get('default_code') or biz.code, 'node_btn_id': ctx.get('default_node_btn_id'), 'workflow_record_id': ctx.get('default_workflow_record_id'), } if is_transfer: 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) if not approvers: raise UserError('下一节点未匹配到审批人,请检查节点配置') wizard_vals['trans_link_id'] = trans_link_id wizard_vals['employee_ids'] = [(6, 0, approvers.ids)] 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, active_id=biz.id, active_ids=biz.ids, ).create(wizard_vals) wizard.add_comment()