Files
gzth/yuthon_ai_agent/models/ai_report.py
T
2026-06-05 19:37:15 +08:00

309 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
import ast
import json
import logging
import uuid
from datetime import timedelta
from odoo import models, fields, api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def _safe_parse_domain(domain_str):
"""安全解析 domain 字符串,支持两种格式:
- Odoo tuple 格式: [('state','=','done'), ('amount_total','>',1000)]
- JSON 格式: [["state","=","done"],["amount_total",">",1000]]
"""
if not domain_str or not domain_str.strip():
return []
domain_str = domain_str.strip()
try:
return ast.literal_eval(domain_str)
except (ValueError, SyntaxError):
try:
return json.loads(domain_str)
except (json.JSONDecodeError, ValueError):
_logger.warning('无法解析 domain: %s', domain_str[:100])
return []
class AiReport(models.Model):
_name = 'ai.report'
_description = '动态报表'
_order = 'create_date desc'
name = fields.Char('报表名称', required=True)
model_name = fields.Char('数据模型', required=True,
help='Odoo 模型技术名称,如 sale.order')
model_id = fields.Many2one('ir.model', string='数据模型', ondelete='cascade',
help='选择数据模型,选择后自动填充"数据模型"字段')
domain = fields.Text('查询条件', default='[]',
help='JSON 格式的 Odoo domain')
field_list = fields.Text('显示字段', required=True,
help='JSON 数组,如 ["name","date_order","partner_id"]')
field_labels = fields.Text('字段标签',
help='JSON 对象,如 {"name":"订单号","date_order":"下单日期"}')
order = fields.Char('排序', default='id desc')
limit = fields.Integer('最大条数', default=200)
access_token = fields.Char('访问令牌', index=True, copy=False)
active = fields.Boolean(default=True)
expire_date = fields.Datetime('过期时间')
template_id = fields.Many2one('ai.report.template', string='使用模板')
params = fields.Text('参数值', default='{}',
help='JSON对象,模板参数的实际值。由AI调用generate_report时传入,如{"date_from":"2025-01-01","customer":"张三"}')
report_data = fields.Text('预存数据',
help='当AI已查询到数据后直接传入时,存储JSON格式的记录数据。'
'优先于实时查询,确保用户看到的数据与AI查询结果一致。')
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('access_token'):
vals['access_token'] = self._generate_token()
if not vals.get('expire_date'):
# 默认 7 天后过期
vals['expire_date'] = fields.Datetime.now() + timedelta(days=7)
# 新增:Auto-sync model_name from model_id
if vals.get('model_id') and not vals.get('model_name'):
model = self.env['ir.model'].browse(vals['model_id'])
vals['model_name'] = model.model
return super().create(vals_list)
@staticmethod
def _generate_token():
"""生成唯一访问令牌"""
return uuid.uuid4().hex
def get_report_url(self, base_url=None):
"""获取报表的完整公开 URL
:param base_url: 可选,覆盖 web.base.url。主要用于企业微信卡片消息
"""
self.ensure_one()
if not base_url:
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
# 如果 web.base.url 是内网地址,检查是否有配置公共 URL
_is_internal = (
'localhost' in base_url or
base_url.startswith('http://127.') or base_url.startswith('https://127.') or
base_url.startswith('http://10.') or base_url.startswith('https://10.') or
base_url.startswith('http://172.') or base_url.startswith('https://172.') or
base_url.startswith('http://192.168') or base_url.startswith('https://192.168')
)
if _is_internal:
public_url = self.env['ir.config_parameter'].sudo().get_param(
'yuthon.public.url', '')
if public_url:
base_url = public_url
else:
_logger.warning(
'web.base.url 是内网地址 %s,但未配置 yuthon.public.url,'
'生成的报表链接外部可能无法访问', base_url
)
return f"{base_url}/ai/report/{self.access_token}"
@api.onchange('model_id')
def _onchange_model_id(self):
if self.model_id:
self.model_name = self.model_id.model
def write(self, vals):
if vals.get('model_id') and 'model_name' not in vals:
model = self.env['ir.model'].browse(vals['model_id'])
vals['model_name'] = model.model
return super().write(vals)
def fetch_data(self):
"""执行查询并返回数据(增强版:支持模板参数合并、字段回退、预存数据)"""
self.ensure_one()
try:
# 如果报表创建时已预存数据(AI 直接传入的查询结果),直接返回
if self.report_data:
cached = json.loads(self.report_data)
if isinstance(cached, dict) and cached.get('records') is not None:
_logger.info(
'报表使用缓存数据: report_id=%s, name=%s, cached_count=%s',
self.id, self.name, cached.get('count', len(cached['records']))
)
return {
'success': True,
'records': cached['records'],
'fields': cached.get('fields', list(cached['records'][0].keys()) if cached['records'] else []),
'count': cached.get('count', len(cached['records'])),
}
else:
_logger.warning(
'报表缓存数据格式异常: report_id=%s, cached=%s',
self.id, str(cached)[:200]
)
model_name = self.model_name
_logger.info(
'报表开始实时查询: report_id=%s, name=%s, model=%s, has_template=%s',
self.id, self.name, model_name, bool(self.template_id)
)
if model_name not in self.env:
_logger.error('报表模型不存在: report_id=%s, model=%s', self.id, model_name)
return {'error': f'模型 {model_name} 不存在'}
Model = self.env[model_name].sudo()
# ---- 1. 构建最终 domain ----
final_domain = _safe_parse_domain(self.domain)
template = self.template_id
# 1a. 如果有模板,合并模板的 default_domain
if template and template.default_domain:
tmpl_domain = _safe_parse_domain(template.default_domain)
if tmpl_domain:
if final_domain:
final_domain = ['&'] + final_domain + tmpl_domain
else:
final_domain = tmpl_domain
# 1b. 如果有模板参数定义 + report 有 params 值 → 生成参数 domain
if template and template.param_definitions and self.params:
try:
param_defs = json.loads(template.param_definitions)
params = json.loads(self.params) if isinstance(self.params, str) else self.params
param_conditions = []
for pdef in param_defs:
param_name = pdef.get('name')
param_value = params.get(param_name)
if param_value is None or param_value == '':
continue
domain_field = pdef.get('domain_field', param_name)
op = pdef.get('op', '=')
param_conditions.append([domain_field, op, param_value])
if param_conditions:
for cond in param_conditions:
final_domain = ['&'] + final_domain + [cond]
except (json.JSONDecodeError, ValueError):
pass
# ---- 2. 构建 field_list ----
field_list = json.loads(self.field_list or '[]')
# 2a. 从模板 field_list 取
if not field_list and template and template.field_list:
field_list = json.loads(template.field_list)
# 2b. 兜底:从模板 field_ids(显示字段 many2many)取
if not field_list and template and template.field_ids:
field_list = [f.name for f in template.field_ids]
_logger.info(
'报表从模板 field_ids 推导字段: report_id=%s, fields=%s',
self.id, field_list
)
# 2c. 最终兜底:从模板 column_ids(列定义)取 field_id
if not field_list and template and template.column_ids:
field_list = template.column_ids.mapped('field_id.name')
field_list = [f for f in field_list if f] # 过滤可能的空值
_logger.info(
'报表从模板 column_ids 推导字段: report_id=%s, fields=%s',
self.id, field_list
)
if not field_list:
_logger.error(
'报表字段列表为空: report_id=%s, field_list=%s, template_field_list=%s, '
'template_field_ids=%s, template_column_ids=%s',
self.id, self.field_list,
template.field_list if template else 'N/A',
template.field_ids.mapped('name') if template else 'N/A',
template.column_ids.mapped('field_id.name') if template else 'N/A'
)
return {'error': '未指定查询字段:模板既没有配置 field_list,也没有 column_ids 中的字段'}
# ---- 3. 执行查询 ----
order = self.order or (template.default_order if template else 'id desc')
limit = self.limit or 200
_logger.info(
'报表执行查询: report_id=%s, domain=%s, field_count=%s, limit=%s, order=%s',
self.id, str(final_domain)[:300], len(field_list), limit, order
)
records = Model.search_read(final_domain, field_list, limit=limit, order=order)
_logger.info('报表查询完成: report_id=%s, count=%s', self.id, len(records))
# ---- 4. 字段标签处理 ----
labels = {}
if self.field_labels:
labels = json.loads(self.field_labels)
elif template and template.field_labels:
labels = json.loads(template.field_labels)
else:
fields_info = Model.fields_get(field_list, attributes=['string', 'type'])
for fname in field_list:
if fname in fields_info:
labels[fname] = fields_info[fname].get('string', fname)
# ---- 5. Many2one/datetime 处理 ----
fields_info = Model.fields_get(field_list, attributes=['type'])
for record in records:
for fname in field_list:
val = record.get(fname)
ftype = fields_info.get(fname, {}).get('type', '')
if ftype == 'many2one' and isinstance(val, (list, tuple)):
record[fname] = val[1] if len(val) > 1 else str(val[0])
elif val is False:
record[fname] = ''
elif ftype in ('date', 'datetime') and val:
record[fname] = str(val) if not isinstance(val, str) else val
return {
'success': True,
'name': self.name,
'fields': field_list,
'labels': labels,
'records': records,
'count': len(records),
}
except Exception as e:
_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()
if not self.active:
return False
if self.expire_date and self.expire_date < fields.Datetime.now():
return False
return True