Files
gzth/soong_workflow/models/soong_workflow_batch_approval.py

383 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()