diff --git a/yuthon_ai_agent/__manifest__.py b/yuthon_ai_agent/__manifest__.py
index 08310b46c..dbce95320 100644
--- a/yuthon_ai_agent/__manifest__.py
+++ b/yuthon_ai_agent/__manifest__.py
@@ -30,6 +30,7 @@
'data/demo_rule.xml',
'data/demo_manual.xml',
'data/demo_report_template.xml',
+ 'data/cron_pinned_reports.xml',
],
'assets': {
'web.assets_backend': [
diff --git a/yuthon_ai_agent/models/ai_agent.py b/yuthon_ai_agent/models/ai_agent.py
index 667c6c8a1..b224e283a 100644
--- a/yuthon_ai_agent/models/ai_agent.py
+++ b/yuthon_ai_agent/models/ai_agent.py
@@ -286,6 +286,28 @@ class AiConversation(models.Model):
},
},
},
+ {
+ 'type': 'function',
+ 'function': {
+ 'name': 'get_report_link',
+ 'description': (
+ '获取报表模板对应的固定报表链接(不需要重新生成)。\n'
+ '当用户需要某个常规报表时,优先使用此工具而不是 generate_report。\n'
+ '如果模板有固定链接(后台已预生成),直接返回链接;'
+ '如果没有固定链接,返回 null 让你知道需要调用 generate_report。'
+ ),
+ 'parameters': {
+ 'type': 'object',
+ 'properties': {
+ 'template_id': {
+ 'type': 'integer',
+ 'description': '报表模板ID,从"可用报表模板"列表中获取',
+ },
+ },
+ 'required': ['template_id'],
+ },
+ },
+ },
]
if self.provider_id.allow_write:
tools += [
@@ -370,6 +392,10 @@ class AiConversation(models.Model):
if name == 'generate_report':
return self._execute_generate_report(args)
+ # get_report_link:获取模板的固定报表链接(无需重新生成)
+ if name == 'get_report_link':
+ return self._execute_get_report_link(args)
+
model_name = args.get('model')
if not model_name or model_name not in self.env:
return {'error': f'未知模型:{model_name}'}
@@ -521,6 +547,25 @@ class AiConversation(models.Model):
'message': f'报表生成失败,请稍后重试或联系管理员检查服务端日志。'
}
+ def _execute_get_report_link(self, args):
+ """获取模板的固定报表链接(不重新生成)"""
+ template_id = args.get('template_id')
+ if not template_id:
+ return {'success': False, 'message': '请提供 template_id'}
+
+ template = self.env['ai.report.template'].sudo().browse(template_id)
+ if not template.exists():
+ return {'success': False, 'message': f'模板 {template_id} 不存在'}
+
+ if template.fixed_report_id and template.fixed_report_id.active:
+ return {
+ 'success': True,
+ 'url': template.fixed_report_url,
+ 'template_name': template.name,
+ 'message': f'已获取"{template.name}"的固定报表链接',
+ }
+ return {'success': False, 'message': f'模板"{template.name}"尚未生成固定链接,请使用 generate_report 临时生成'}
+
def _call_chat_completion(self, provider, messages, tools=None, retry=2):
"""封装一次 HTTP 调用,返回 (assistant_message_dict, error_str)。
同时记录 Token 使用量。支持自动重试瞬态网络错误。
@@ -710,19 +755,24 @@ class AiConversation(models.Model):
'少量数据(≤5条)直接用编号列表展示,如:\n'
'1. 张三 / 研发部 / 2025-03-01\n'
'2. 李四 / 财务部 / 2025-03-15\n'
- '【报表与推送】当查询结果超过5条,或用户要求"列表/报表/明细"时,'
- '必须调用 generate_report 工具生成报表链接。\n'
- '调用方式:用 odoo_search_read 查到数据后,将返回的 records 数组直接传入 '
- 'generate_report 的 records 参数,只需再填 name 即可。'
- '示例:generate_report({"name":"花名册", "records":<上一步search_read的records>})\n'
+ '【报表与推送】当用户要求查看列表/报表/明细时,'
+ '优先调用 get_report_link 工具:\n'
+ ' 1) 从"可用报表模板"中找到匹配的 template_id\n'
+ ' 2) 调用 get_report_link({"template_id": XX}) 获取固定链接\n'
+ ' 3) 如果返回 success=true,直接把 url 给用户(无需重新生成)\n'
+ ' 4) 如果返回 success=false(模板未生成固定链接),再调用 generate_report 临时生成\n'
+ '【get_report_link 返回值】success=true 时,url 字段是固定链接,直接展示给用户。\n'
+ '【generate_report 推荐用法】先用 odoo_search_read 查询数据,将返回的 records '
+ '数组直接传入 records 参数。这样无需重复指定 model/domain/fields,'
+ '且用户看到的数据与你的查询结果完全一致。\n'
'【严禁编造链接!】你绝对不能自己编造、猜测或拼接报表 URL。'
- '报表链接只能从 generate_report 工具返回的 url 字段中获取,'
+ '报表链接只能从 get_report_link 或 generate_report 工具返回的 url 字段中获取,'
'直接用工具返回的 URL,不要做任何修改。'
'如果你自己拼一个 /ai/report/xxx 或任何自创的链接格式,用户点击后会看到"报表不存在"。\n'
'生成报表后系统会自动尝试推送到用户的企微。无论推送是否成功,'
'回复时都要说明查询结果并附上工具返回的真实链接,如:\n'
'已为您生成报表,共查询到 XX 条记录:\n'
- 'https://oa.thtzjt.com/ai/report/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n'
+ 'https://oa.thtzjt.com/ai/report/xxxxxxxxxxxxxxxxxxxxxxxx\n'
'查询结果中的时间已经转换为北京时间(UTC+8),直接展示即可,无需再做时区转换。\n\n'
'【重要】你具有记忆能力。当对话中发现用户的新偏好、工作内容、习惯等值得长期记住的信息时,'
'主动调用 save_memory 工具保存。下次对话你会自动获取这些记忆。\n'
@@ -756,9 +806,10 @@ class AiConversation(models.Model):
# —— 注入报表模板 ——
templates_info = self.env['ai.report.template'].get_templates_for_ai()
if templates_info:
- system_msg += '\n--- 可用报表模板(调用 generate_report 时可参考)---\n'
+ system_msg += '\n--- 可用报表模板(调用 get_report_link 或 generate_report 时可参考)---\n'
system_msg += templates_info + '\n'
- system_msg += '当用户查询相关数据时,优先使用对应模板的字段和条件生成报表。\n'
+ system_msg += '当用户查询相关数据时,优先调用 get_report_link(template_id) 获取固定链接;'
+ '如无固定链接,再使用 generate_report 配合模板生成报表。\n'
if user_context:
system_msg += f'\n--- 当前用户信息 ---\n{user_context}\n'
diff --git a/yuthon_ai_agent/models/ai_report.py b/yuthon_ai_agent/models/ai_report.py
index f788e4508..5562bf208 100644
--- a/yuthon_ai_agent/models/ai_report.py
+++ b/yuthon_ai_agent/models/ai_report.py
@@ -219,6 +219,39 @@ class AiReport(models.Model):
_logger.exception('Report fetch_data error: %s', e)
return {'error': str(e)}
+ def refresh_data(self):
+ """强制实时查询并更新 report_data 缓存。
+
+ 用于定时任务刷新固定报表数据:
+ - 清空 report_data 缓���
+ - 执行实时查询
+ - 将结果写回 report_data
+ """
+ self.ensure_one()
+ old_data = self.report_data
+ self.report_data = False # 清除缓存,强制实时查询
+ try:
+ result = self.fetch_data()
+ if result.get('success'):
+ self.report_data = json.dumps(result, ensure_ascii=False, default=str)
+ _logger.info(
+ '固定报表刷新成功: template_id=%s, report_id=%s, count=%s',
+ self.template_id.id, self.id, result.get('count', 0)
+ )
+ return result
+ else:
+ # 查询失败,恢复旧数据
+ self.report_data = old_data
+ _logger.error(
+ '固定报表刷新失败: template_id=%s, report_id=%s, error=%s',
+ self.template_id.id, self.id, result.get('error', 'unknown')
+ )
+ return result
+ except Exception as e:
+ self.report_data = old_data
+ _logger.exception('固定报表刷新异常: %s', e)
+ return {'error': str(e)}
+
def is_valid(self):
"""检查报表是否有效(未过期、启用状态)"""
self.ensure_one()
diff --git a/yuthon_ai_agent/models/ai_report_template.py b/yuthon_ai_agent/models/ai_report_template.py
index e6b2310d8..0824ae075 100644
--- a/yuthon_ai_agent/models/ai_report_template.py
+++ b/yuthon_ai_agent/models/ai_report_template.py
@@ -37,6 +37,15 @@ class AiReportTemplate(models.Model):
sequence = fields.Integer('排序', default=10)
active = fields.Boolean(default=True)
+ # ===== 固定报表 =====
+ fixed_report_id = fields.Many2one(
+ 'ai.report', string='固定报表',
+ copy=False, ondelete='set null',
+ help='点击"生成固定链接"后生成,报表不过期,每天凌晨自动刷新数据')
+ fixed_report_url = fields.Char(
+ '固定链接', compute='_compute_fixed_report_url',
+ help='固定报表的访问链接,可直接发给用户')
+
# ===== 视觉配置(拆分为 One2many) =====
column_ids = fields.One2many('ai.report.template.column', 'template_id',
string='列定义', copy=True)
@@ -285,4 +294,103 @@ class AiReportTemplate(models.Model):
vals['field_labels'] = json.dumps(
{f.name: f.field_description for f in fields_records},
ensure_ascii=False
- )
\ No newline at end of file
+ )
+
+ # ===== 固定报表:计算字段 =====
+ @api.depends('fixed_report_id.access_token', 'fixed_report_id.active')
+ def _compute_fixed_report_url(self):
+ for t in self:
+ if t.fixed_report_id and t.fixed_report_id.active:
+ t.fixed_report_url = t.fixed_report_id.get_report_url()
+ else:
+ t.fixed_report_url = False
+
+ # ===== 固定报表:生成/刷新动作 =====
+ def action_generate_fixed_report(self):
+ """生成固定报表链接(按钮动作)
+
+ - 若 fixed_report_id 为空:创建一条不过期的 ai.report 记录
+ - 若已存在:刷新数据(调用 report.refresh_data)
+ """
+ self.ensure_one()
+ if self.fixed_report_id:
+ # 已存在:刷新数据
+ result = self.fixed_report_id.refresh_data()
+ if result.get('error'):
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': '刷新失败',
+ 'message': result['error'],
+ 'type': 'danger',
+ }
+ }
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': '刷新成功',
+ 'message': f'报表数据已更新,共 {result.get("count", 0)} 条记录',
+ 'type': 'success',
+ }
+ }
+
+ # 不存在:创建固定报表
+ report = self.env['ai.report'].sudo().create({
+ 'name': self.name,
+ 'model_name': self.model_name,
+ 'field_list': self.field_list or '[]',
+ 'field_labels': self.field_labels or '{}',
+ 'domain': self.default_domain or '[]',
+ 'order': self.default_order or 'id desc',
+ 'template_id': self.id,
+ 'expire_date': False, # 不过期
+ 'active': True,
+ })
+ self.fixed_report_id = report
+ url = report.get_report_url()
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': '固定链接已生成',
+ 'message': f'链接:{url}',
+ 'type': 'success',
+ 'sticky': True,
+ }
+ }
+
+ # ===== 定时任务:刷新所有固定报表 =====
+ @api.model
+ def _cron_refresh_pinned_reports(self):
+ """凌晨1点定时任务:刷新所有固定报表数据
+
+ 遍历所有 fixed_report_id 非空且 active 的模板,
+ 调用对应 report.refresh_data() 刷新缓存数据。
+ """
+ templates = self.sudo().search([
+ ('fixed_report_id', '!=', False),
+ ('fixed_report_id.active', '=', True),
+ ])
+ refreshed, failed = 0, 0
+ for t in templates:
+ report = t.fixed_report_id
+ try:
+ result = report.refresh_data()
+ if result.get('error'):
+ failed += 1
+ _logger.error(
+ '固定报表刷新失败: template=%s(%s), report=%s, error=%s',
+ t.name, t.id, report.id, result['error']
+ )
+ else:
+ refreshed += 1
+ except Exception as e:
+ failed += 1
+ _logger.exception('固定报表刷新异常: template=%s(%s)', t.name, t.id)
+ _logger.info(
+ '固定报表定时刷新完成: 成功=%s, 失败=%s, 总计=%s',
+ refreshed, failed, len(templates)
+ )
+ return {'refreshed': refreshed, 'failed': failed}
\ No newline at end of file
diff --git a/yuthon_ai_agent/views/ai_report_views.xml b/yuthon_ai_agent/views/ai_report_views.xml
index ea0dd1ae2..c3a3eee87 100644
--- a/yuthon_ai_agent/views/ai_report_views.xml
+++ b/yuthon_ai_agent/views/ai_report_views.xml
@@ -107,6 +107,7 @@