# -*- 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/', 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//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''' 报表错误
⚠
{message}
'''