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 @@
+ confirm="你确定要一键审批选中的待办吗?"/>
+ 行为:勾选了卡片 → 只批勾选的;没勾选 → 批全部待办(与列表视图语义一致,支持加班/请假/外出) -->
soong.workflow.record.kanban.batch.all
soong.workflow.record