309 lines
14 KiB
Python
309 lines
14 KiB
Python
# -*- 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
|