一键审批,加班,外出,请假

This commit is contained in:
李鹏宇
2026-08-10 17:28:32 +08:00
parent c3e8e25194
commit 57ecb30d3f
2 changed files with 266 additions and 75 deletions
@@ -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,
@@ -10,13 +10,13 @@
<xpath expr="//header" position="inside">
<button name="action_batch_approve" type="object" string="一键审批"
class="oe_highlight"
confirm="你确定要把加班都审批吗?"/>
confirm="你确定要一键审批选中的待办吗?"/>
</xpath>
</field>
</record>
<!-- 在“我的待办”kanban(soong.workflow.record.kanban)增加勾选框 + 一键审批按钮
行为:勾选了卡片 → 只批勾选的;没勾选 → 批全部待办加班(与列表视图语义一致) -->
行为:勾选了卡片 → 只批勾选的;没勾选 → 批全部待办(与列表视图语义一致,支持加班/请假/外出) -->
<record id="soong_workflow_record_kanban_batch_approve_all" model="ir.ui.view">
<field name="name">soong.workflow.record.kanban.batch.all</field>
<field name="model">soong.workflow.record</field>