From 48f223ce22c6a5a688f22fe495bb4fafca6bf9c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= Date: Fri, 5 Jun 2026 18:59:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A5=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yuthon_ai_agent/__manifest__.py | 1 + yuthon_ai_agent/models/ai_agent.py | 69 ++++++++++-- yuthon_ai_agent/models/ai_report.py | 33 ++++++ yuthon_ai_agent/models/ai_report_template.py | 110 ++++++++++++++++++- yuthon_ai_agent/views/ai_report_views.xml | 9 ++ 5 files changed, 212 insertions(+), 10 deletions(-) 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 @@ + @@ -132,6 +133,10 @@

+