diff --git a/yuthon_ai_agent/models/ai_agent.py b/yuthon_ai_agent/models/ai_agent.py
index 98b981611..29158178b 100644
--- a/yuthon_ai_agent/models/ai_agent.py
+++ b/yuthon_ai_agent/models/ai_agent.py
@@ -466,26 +466,29 @@ class AiConversation(models.Model):
return {'error': f'{type(e).__name__}: {e}'}
def _execute_skill_tool(self, name, args):
- """尝试执行技能规则工具,找不到返回 None"""
+ """尝试执行技能/方法规则工具,找不到返回 None"""
provider = self.provider_id
if not provider:
return None
- skill_rule = self.env['ai.rule'].sudo().search([
- ('rule_type', '=', 'skill'),
+ # 先查 skill 类型
+ rule = self.env['ai.rule'].sudo().search([
+ ('rule_type', 'in', ['skill', 'method']),
('skill_name', '=', name),
('active', '=', True),
('provider_ids', 'in', [provider.id]),
], limit=1)
- if not skill_rule:
- # 也搜索不限服务商的全局技能
- skill_rule = self.env['ai.rule'].sudo().search([
- ('rule_type', '=', 'skill'),
+ if not rule:
+ # 也搜索不限服务商的全局规则
+ rule = self.env['ai.rule'].sudo().search([
+ ('rule_type', 'in', ['skill', 'method']),
('skill_name', '=', name),
('active', '=', True),
('provider_ids', '=', False),
], limit=1)
- if skill_rule:
- return skill_rule.execute_skill(args)
+ if rule:
+ if rule.rule_type == 'method':
+ return rule.execute_method(args)
+ return rule.execute_skill(args)
return None
def _execute_generate_report(self, args):
@@ -875,7 +878,7 @@ class AiConversation(models.Model):
applicable_rules = provider.get_applicable_rules()
instruction_rules = applicable_rules.filtered(lambda r: r.rule_type == 'instruction')
restriction_rules = applicable_rules.filtered(lambda r: r.rule_type == 'restriction')
- skill_rules = applicable_rules.filtered(lambda r: r.rule_type == 'skill' and r.skill_name)
+ skill_rules = applicable_rules.filtered(lambda r: r.rule_type in ('skill', 'method') and r.skill_name)
if instruction_rules:
system_msg += '\n--- 回复指令(必须遵守)---\n'
diff --git a/yuthon_ai_agent/models/ai_rule.py b/yuthon_ai_agent/models/ai_rule.py
index 7b34cdcb8..909a395d6 100644
--- a/yuthon_ai_agent/models/ai_rule.py
+++ b/yuthon_ai_agent/models/ai_rule.py
@@ -23,10 +23,12 @@ class AiRule(models.Model):
('instruction', '回复指令'),
('restriction', '限制规则'),
('skill', '技能接口'),
+ ('method', '调用模型方法'),
], string='规则类型', required=True, default='instruction',
help='回复指令:注入到 AI 系统提示词中,控制回复风格和内容。\n'
'限制规则:告诉 AI 哪些话题不能回答。\n'
- '技能接口:对接外部 API,AI 可调用获取专业数据。'
+ '技能接口:对接外部 API,AI 可调用获取专业数据。\n'
+ '调用模型方法:直接调用 Odoo 模型方法(如撤回消息),无需 HTTP 接口。'
)
active = fields.Boolean(string='启用', default=True)
priority = fields.Integer(string='优先级', default=10, help='数字越小优先级越高')
@@ -81,6 +83,16 @@ class AiRule(models.Model):
string='接口超时(秒)', default=10,
)
+ # ---- 调用模型方法配置 ----
+ skill_model = fields.Char(
+ string='目标模型',
+ help='方法所在模型的技术名称,如 attendance.message.log。仅 method 类型使用。',
+ )
+ skill_method_name = fields.Char(
+ string='方法名称',
+ help='要调用的模型方法名,如 action_recall_message。仅 method 类型使用。',
+ )
+
# ---- 技能接口执行 ----
def execute_skill(self, arguments):
"""调用技能接口,返回结果字典"""
@@ -121,7 +133,7 @@ class AiRule(models.Model):
return {'error': '技能接口返回格式错误(非 JSON)'}
def get_tool_schema(self):
- """将技能规则转换为 OpenAI 兼容的 tool schema"""
+ """将技能规则转换为 OpenAI 兼容的 tool schema(skill 和 method 类型通用)"""
self.ensure_one()
try:
params = json.loads(self.skill_parameters or '{}')
@@ -135,3 +147,48 @@ class AiRule(models.Model):
'parameters': params,
},
}
+
+ def execute_method(self, arguments):
+ """执行模型方法调用(rule_type='method')。搜索目标模型记录,逐条调用指定方法。"""
+ self.ensure_one()
+ if self.rule_type != 'method':
+ return {'error': '该规则不是调用模型方法类型'}
+ if not self.skill_model or self.skill_model not in self.env:
+ return {'error': f'未配置目标模型或模型不存在:{self.skill_model}'}
+ if not self.skill_method_name:
+ return {'error': '未配置方法名称'}
+
+ Model = self.env[self.skill_model]
+ method = getattr(Model, self.skill_method_name, None)
+ if not method:
+ return {'error': f'模型 {self.skill_model} 中没有方法 {self.skill_method_name}'}
+
+ try:
+ domain = arguments.get('domain') or []
+ ids = arguments.get('ids') or []
+
+ if ids:
+ records = Model.browse(ids).exists()
+ else:
+ records = Model.search(domain)
+
+ if not records:
+ return {'success': True, 'message': '没有找到符合条件的记录', 'count': 0}
+
+ results = []
+ for rec in records:
+ try:
+ res = method(rec)
+ results.append({'id': rec.id, 'result': str(res) if res else None})
+ except Exception as e:
+ results.append({'id': rec.id, 'error': str(e)})
+
+ return {
+ 'success': True,
+ 'count': len(records),
+ 'results': results,
+ }
+ except Exception as e:
+ _logger.exception('Method rule %s failed: %s', self.skill_name, e)
+ return {'error': f'{type(e).__name__}: {e}'}
+
diff --git a/yuthon_ai_agent/views/ai_rule_views.xml b/yuthon_ai_agent/views/ai_rule_views.xml
index ce4a48397..ee3c0ce3b 100644
--- a/yuthon_ai_agent/views/ai_rule_views.xml
+++ b/yuthon_ai_agent/views/ai_rule_views.xml
@@ -13,7 +13,8 @@
+ decoration-success="rule_type == 'skill'"
+ decoration-danger="rule_type == 'method'"/>
@@ -58,7 +59,7 @@
+ invisible="rule_type in ('skill', 'method')">
@@ -88,6 +89,28 @@
placeholder='{"Authorization": "Bearer xxx"}'/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -110,6 +133,8 @@
domain="[('rule_type','=','restriction')]"/>
+
diff --git a/yuthon_hr_attendance/models/yuthon_vacation_reports.py b/yuthon_hr_attendance/models/yuthon_vacation_reports.py
index 03b6e37c5..f780afb1a 100644
--- a/yuthon_hr_attendance/models/yuthon_vacation_reports.py
+++ b/yuthon_hr_attendance/models/yuthon_vacation_reports.py
@@ -3556,6 +3556,7 @@ class VacationOtherLine(models.Model):
class AttendanceMessageLog(models.Model):
_name = 'attendance.message.log'
_description = '消息提醒日志'
+ _order = 'update_date desc'
vacation_id = fields.Many2one('yuthon.vacation.reports', string='关联假期额度记录')
employee_id = fields.Many2one('hr.employee', string='员工')
@@ -3568,6 +3569,34 @@ class AttendanceMessageLog(models.Model):
notice_type = fields.Selection([('attendance', '出勤'), ('schedule', '日程'), ('announcement', '公告'), ('attendance_confirmation', '考勤确认单'), ('salary_slip', '薪资条'),
('process', '流程'), ('other', '其他')], string='类型')
message_url = fields.Char(string="url")
+ msg_id = fields.Char(string='消息ID')
+ category = fields.Char(string='消息类别')
+
+ def action_recall_message(self):
+ """撤回企微消息(调用企微back_message接口)"""
+ self.ensure_one()
+ if not self.msg_id:
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': '撤回失败',
+ 'message': '消息ID为空,无法撤回',
+ 'type': 'warning',
+ }
+ }
+ category = self.category or 'att'
+ self.env['wecom.apps'].back_message(self.msg_id, category=category)
+ self.msg_id = '已撤回'
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': '撤回成功',
+ 'message': f'消息已撤回(msg_id={self.msg_id})',
+ 'type': 'success',
+ }
+ }
@api.depends('employee_id')
def _compute_shift_id(self):
diff --git a/yuthon_hr_attendance/views/attendance_message_log.xml b/yuthon_hr_attendance/views/attendance_message_log.xml
index 68ebe5675..2b4269b1a 100644
--- a/yuthon_hr_attendance/views/attendance_message_log.xml
+++ b/yuthon_hr_attendance/views/attendance_message_log.xml
@@ -4,6 +4,7 @@
attendance.message.log
+
@@ -12,6 +13,8 @@
+
+
@@ -33,7 +36,7 @@
消息提醒日志
attendance.message.log
tree,search
- {'search_default_group_notice_type': 1}
+ 20