Files
gzth/yuthon_ai_agent/controllers/report.py
T
2026-06-05 19:48:51 +08:00

243 lines
10 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 json
import logging
import os
from odoo import http, api, SUPERUSER_ID, fields
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
# H5 模板文件路径
_H5_TEMPLATE_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'static', 'src', 'html', 'report_template.html'
)
class AiReportController(http.Controller):
"""AI 动态报表公开控制器"""
def _get_report_by_token(self, token):
"""根据 token 查找并验证报表"""
db_name = request.env.cr.dbname if hasattr(request.env.cr, 'dbname') else 'unknown'
_logger.info(
'报表访问请求: token=%s, db=%s, uid=%s, remote=%s',
token[:12], db_name, request.env.uid,
request.httprequest.remote_addr if hasattr(request, 'httprequest') else 'unknown'
)
report = request.env['ai.report'].sudo().search([
('access_token', '=', token),
], limit=1)
if not report:
_logger.warning(
'报表不存在: token=%s, db=%s, 可能原因: token错误/数据库不匹配/记录已被删除',
token[:12], db_name
)
return None, '报表不存在或已被删除'
if not report.is_valid():
_logger.warning('报表已失效: token=%s, db=%s, expire=%s, active=%s',
token[:12], db_name, report.expire_date, report.active)
reason = '报表已过期' if (report.expire_date and report.expire_date < fields.Datetime.now()) else '报表已停用'
return None, f'{reason},请联系分享者重新生成'
_logger.info('报表验证通过: token=%s, report_id=%s, name=%s',
token[:12], report.id, report.name)
return report, None
@http.route('/ai/report/<string:token>', type='http', auth='none',
methods=['GET'], csrf=False)
def report_page(self, token, **kw):
"""渲染 H5 报表页面(增强版:动态注入 REPORT_CONFIG)"""
report, error = self._get_report_by_token(token)
if error:
return Response(
self._render_error_page(error),
content_type='text/html; charset=utf-8'
)
# 1. 获取基础数据
data = report.fetch_data()
# 2. 构建 REPORT_CONFIG
config = {}
template = report.template_id
# 2a. 标题
config['title'] = report.name
# 2b. 列配置:优先使用模板的 report_config.columns
if template and template.report_config:
try:
tmpl_config = json.loads(template.report_config)
config['columns'] = tmpl_config.get('columns', [])
config['cards'] = tmpl_config.get('cards', [])
config['groupBy'] = tmpl_config.get('groupBy', [])
config['defaultVisible'] = tmpl_config.get('defaultVisible', 10)
config['colorRules'] = tmpl_config.get('colorRules', [])
config['filters'] = tmpl_config.get('filters', [])
except json.JSONDecodeError:
pass
# 2c. 兜底:如果 report_config 没有列配置,直接从 column_ids 构建
if not config.get('columns') and template and template.column_ids:
columns = []
for c in template.column_ids:
fname = c.field_name or (c.field_id.name if c.field_id else '')
if not fname:
continue
columns.append({
'k': fname,
'l': c.label or fname,
'w': c.width or 120,
})
if columns:
config['columns'] = columns
_logger.info(
'报表列配置从 column_ids 构建: report_id=%s, cols=%s',
report.id, len(columns)
)
# 2d. 最终兜底:从 report 自身字段生成
if not config.get('columns'):
fields = json.loads(report.field_list or '[]')
labels = {}
if report.field_labels:
labels = json.loads(report.field_labels)
config['columns'] = [
{'k': f, 'l': labels.get(f, f), 'w': 120}
for f in fields
]
else:
# 2e. 兜底校验 — 如果模板 column 的 key 在数据中不存在,用数据字段重建
data_records = data.get('records', [])
if data_records:
first = data_records[0]
col_keys = {c.get('k', '') for c in config['columns']}
data_keys = set(first.keys())
# 排除 id 字段(Odoo search_read 总是返回 id)
display_keys = data_keys - {'id'}
missing = col_keys - data_keys
extra = display_keys - col_keys
if missing:
_logger.warning(
'模板 %s (id=%s) report_config 列 key 与数据不匹配: '
'missing=%s, extra=%s, col_keys=%s, data_keys=%s',
template.name, template.id, missing, extra, col_keys, data_keys)
# 从数据字段重建列,但尝试保留模板中的 label 和 width
old_col_map = {c.get('k', ''): c for c in config['columns']}
labels = {}
if report.field_labels:
labels = json.loads(report.field_labels)
elif template and template.field_labels:
labels = json.loads(template.field_labels)
fields = data.get('fields', list(display_keys))
config['columns'] = [
{
'k': f,
'l': labels.get(f, old_col_map.get(f, {}).get('l', f)),
'w': old_col_map.get(f, {}).get('w', 120),
}
for f in fields if f != 'id'
]
config['_column_mismatch'] = True # 标记列被重建,前端可用此提示
_logger.info(
'模板 %s: 列已从数据字段重建,共 %s 列', template.name, len(config['columns'])
)
# 2f. 处理数据加载失败
if not data.get('success'):
_logger.error(
'报表页面数据加载失败: report_id=%s, token=%s, error=%s',
report.id, token[:12], data.get('error', 'unknown')
)
config['data'] = []
config['description'] = data.get('error', '数据加载失败')
else:
_logger.info(
'报表页面数据加载成功: report_id=%s, token=%s, count=%s',
report.id, token[:12], data.get('count', 0)
)
# 注入数据
config['data'] = data.get('records', [])
config['description'] = "共 {} 条记录".format(data.get('count', 0))
# 2g. 子标题(数据概要)
fields = data.get('fields', [])
records_count = data.get('count', 0)
config['subtitle'] = "{} 个字段 · {} 条记录".format(len(fields), records_count)
# 3. 读取 H5 模板
try:
with open(_H5_TEMPLATE_PATH, 'r', encoding='utf-8') as f:
html_template = f.read()
except FileNotFoundError:
return Response('模板文件未找到', content_type='text/plain')
# 4. 注入占位符
config_json_str = json.dumps(config, ensure_ascii=False, default=str)
html_content = html_template.replace('{{REPORT_CONFIG_JSON}}', config_json_str)
html_content = html_content.replace('{{DATA_URL}}', '/ai/report/{}/data'.format(token))
html_content = html_content.replace('{{REPORT_TITLE}}', report.name or '动态报表')
return Response(html_content, content_type='text/html; charset=utf-8')
@http.route('/ai/report/<string:token>/data', type='http', auth='none',
methods=['GET'], csrf=False)
def report_data(self, token, **kw):
"""返回报表数据(JSON API)"""
report, error = self._get_report_by_token(token)
if error:
return Response(
json.dumps({'error': error}, ensure_ascii=False),
content_type='application/json; charset=utf-8'
)
data = report.fetch_data()
if not data.get('success'):
_logger.error(
'报表数据API加载失败: report_id=%s, token=%s, error=%s',
report.id, token[:12], data.get('error', 'unknown')
)
else:
_logger.info(
'报表数据API加载成功: report_id=%s, token=%s, count=%s',
report.id, token[:12], data.get('count', 0)
)
return Response(
json.dumps(data, ensure_ascii=False, default=str),
content_type='application/json; charset=utf-8'
)
@staticmethod
def _render_error_page(message):
"""渲染简单的错误页面"""
return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>报表错误</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; justify-content: center; align-items: center;
min-height: 100vh; margin: 0;
background: #f5f5f5; color: #333;
}}
.error-box {{
text-align: center; padding: 40px;
background: white; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}}
.error-icon {{ font-size: 48px; margin-bottom: 16px; }}
.error-msg {{ font-size: 16px; color: #666; }}
</style>
</head>
<body>
<div class="error-box">
<div class="error-icon">&#9888;</div>
<div class="error-msg">{message}</div>
</div>
</body>
</html>'''