383 lines
19 KiB
Python
383 lines
19 KiB
Python
# -*- 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()
|