Files
gzth/yuthon_ai_agent/models/ai_report_template.py
T
2026-06-10 16:57:46 +08:00

593 lines
24 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 re
from odoo import models, fields, api
from datetime import timedelta
from lxml import etree
_logger = logging.getLogger(__name__)
class AiReportTemplate(models.Model):
_name = 'ai.report.template'
_description = '报表模板'
_order = 'sequence, id'
# ===== 基础配置 =====
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='选择数据模型,选择后自动填充"数据模型"字段')
field_list = fields.Text('字段列表',
help='JSON 数组,如 ["name","date_order","partner_id","amount_total"]')
field_labels = fields.Text('字段标签',
help='JSON 对象,如 {"name":"订单号","amount_total":"金额"}')
field_ids = fields.Many2many('ir.model.fields', 'ai_report_template_field_rel',
'template_id', 'field_id', string='显示字段',
domain="[('model_id','=',model_id)]",
help='选择要显示的字段。选择后自动填充"字段列表"和"字段标签"')
default_domain = fields.Char('默认查询条件', default="[]",
help='Odoo domain 格式,如 [("state","=","sale"),("amount_total",">",1000)]')
default_order = fields.Char('默认排序', default='id desc')
description = fields.Text('使用说明', translate=True,
help='告诉 AI 何时使用此模板')
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='固定报表的访问链接,可直接发给用户')
fixed_report_url_stored = fields.Char(
'固定链接', copy=False, index=True,
help='固定报表的访问链接(存储字段,生成/刷新时自动更新)')
# ===== 视觉配置(拆分为 One2many) =====
column_ids = fields.One2many('ai.report.template.column', 'template_id',
string='列定义', copy=True)
card_ids = fields.One2many('ai.report.template.card', 'template_id',
string='汇总卡片', copy=True)
group_by_ids = fields.Many2many('ir.model.fields',
'ai_report_template_group_rel', 'template_id', 'field_id',
string='分组设置', copy=True,
domain="[('model_id', '=', model_id)]")
filter_ids = fields.Many2many('ir.model.fields',
'ai_report_template_filter_rel', 'template_id', 'field_id',
string='筛选器', copy=True,
domain="[('model_id', '=', model_id)]")
column_field_ids = fields.Many2many('ir.model.fields',
string='列定义字段集合',
compute='_compute_column_field_ids',
help='列定义中已配置的字段,用于限制分组/筛选器的可选范围')
param_def_ids = fields.One2many('ai.report.template.param.def', 'template_id',
string='AI 参数定义', copy=True)
chart_ids = fields.One2many('ai.report.template.chart', 'template_id',
string='图表配置', copy=True)
default_visible = fields.Integer('默认可见列数', default=10)
# ===== 计算字段(兼容旧版 JSON) =====
report_config = fields.Text('报表视觉配置', default='{}', compute='_compute_report_config',
inverse='_inverse_report_config', store=True,
help='自动从列/卡片/分组/颜色/筛选器生成,也可手动编辑 JSON')
param_definitions = fields.Text('模板参数定义', default='[]',
compute='_compute_param_definitions', inverse='_inverse_param_definitions', store=True,
help='自动从参数定义行生成,也可手动编辑 JSON')
# ===== Computed: column_field_ids =====
@api.depends('column_ids.field_id')
def _compute_column_field_ids(self):
for t in self:
t.column_field_ids = t.column_ids.mapped('field_id')
# ===== Computed: report_config =====
@api.depends('column_ids', 'card_ids', 'group_by_ids',
'filter_ids', 'default_visible', 'chart_ids')
def _compute_report_config(self):
for t in self:
cfg = {}
cols = []
skipped_cols = 0
for c in t.column_ids:
fname = c.field_name or (c.field_id.name if c.field_id else '')
flabel = c.label or fname
if not fname:
skipped_cols += 1
continue
cols.append({'k': fname, 'l': flabel, 'w': c.width or 120})
if skipped_cols > 0:
_logger.warning(
'模板 %s (id=%s): %s 个列定义的字段名为空,已被跳过。'
'请检查列定义中的字段名或字段选择。',
t.name, t.id, skipped_cols
)
# 如果 column_ids 有数据但全部被跳过,需要重置 report_config
if cols:
cfg['columns'] = cols
elif t.column_ids:
# column_ids 存在但全部无效 → 清除旧的 report_config
_logger.warning(
'模板 %s (id=%s): 所有列定义无效,report_config 将被清空。', t.name, t.id
)
cards = []
for c in t.card_ids:
fname_c = c.field_name or (c.field_id.name if c.field_id else '')
if not fname_c:
continue
d = {'label': c.label, 'field': fname_c, 'calc': c.calc}
if c.prefix:
d['prefix'] = c.prefix
if c.unit:
d['unit'] = c.unit
cards.append(d)
if cards:
cfg['cards'] = cards
gb = [{'key': g.name, 'label': g.field_description}
for g in t.group_by_ids]
if gb:
cfg['groupBy'] = gb
cr = []
for col in t.column_ids:
if col.color_value and col.cell_color:
col_fname = col.field_name or (col.field_id.name if col.field_id else '')
if not col_fname:
continue
op_map = {'eq': 'eq', 'neq': 'neq', 'gt': '>', 'lt': '<',
'gte': '>=', 'lte': '<=', 'contains': 'contains'}
op = op_map.get(col.color_operator or 'eq', 'eq')
cr.append({
'field': col_fname,
'rules': [{'op': op, 'value': col.color_value.strip(),
'color': col.cell_color}],
})
if cr:
# 合并相同 field 的规则
merged = {}
for r in cr:
f = r['field']
if f not in merged:
merged[f] = {'field': f, 'rules': []}
merged[f]['rules'].append(r['rules'][0])
cfg['colorRules'] = list(merged.values())
flt = [{'field': f.name, 'type': 'input',
'label': f.field_description}
for f in t.filter_ids]
if flt:
cfg['filters'] = flt
if t.default_visible:
cfg['defaultVisible'] = t.default_visible
charts = []
for ch in t.chart_ids:
lf_name = ch.label_field_name or (ch.label_field_id.name if ch.label_field_id else '')
vf_name = ch.value_field_name or (ch.value_field_id.name if ch.value_field_id else '')
if not lf_name:
continue
cd = {
'type': ch.chart_type,
'labelField': lf_name,
'calc': ch.calc_method,
'title': ch.title or '',
}
if vf_name:
cd['valueField'] = vf_name
charts.append(cd)
if charts:
cfg['charts'] = charts
t.report_config = json.dumps(cfg, ensure_ascii=False) if cfg else '{}'
def _inverse_report_config(self):
"""反向:手动编辑 JSON 时回写旧字段值(不自动拆分到行,保持兼容)"""
pass # report_config 仍可手动编辑,但不反向拆分到行
# ===== Computed: param_definitions =====
@api.depends('param_def_ids')
def _compute_param_definitions(self):
for t in self:
if not t.param_def_ids:
continue
defs = []
for p in t.param_def_ids:
d = {
'name': p.name,
'label': p.label or p.name,
'type': p.param_type,
'domain_field': p.domain_field or p.name,
'op': p.op or '=',
'description': p.description or '',
}
if p.options:
d['options'] = [o.strip() for o in p.options.split(',') if o.strip()]
if p.required:
d['required'] = True
defs.append(d)
t.param_definitions = json.dumps(defs, ensure_ascii=False) if defs else '[]'
def _inverse_param_definitions(self):
pass
# ===== 预览动作 =====
def action_preview(self):
"""预览报表:使用模板配置生成一个临时报表并打开 URL"""
self.ensure_one()
report = self.env['ai.report'].sudo().create({
'name': f'[预览] {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': fields.Datetime.now() + timedelta(hours=1),
})
url = report.get_report_url()
return {
'type': 'ir.actions.act_url',
'url': url,
'target': 'new',
}
# ===== AI 模板注入 =====
@api.model
def get_templates_for_ai(self):
"""获取所有启用模板的摘要,供 AI system_msg 注入"""
templates = self.sudo().search([('active', '=', True)])
if not templates:
return ''
lines = []
for t in templates:
fields_str = t.field_list or '[]'
base_info = (
f'- 模板"{t.name}":ID={t.id},模型={t.model_name},'
f'字段={fields_str},说明={t.description or "无"}'
)
if t.param_definitions:
try:
param_defs = json.loads(t.param_definitions)
if param_defs:
param_info = ',参数: ' + ', '.join([
f'{p.get("name","")}({p.get("label","")}:{p.get("type","")})'
for p in param_defs
])
base_info += param_info
except json.JSONDecodeError:
pass
lines.append(base_info)
return '\n'.join(lines)
# ===== onchange =====
@api.onchange('model_id')
def _onchange_model_id(self):
if self.model_id:
self.model_name = self.model_id.model
@api.onchange('field_ids')
def _onchange_field_ids(self):
if self.field_ids:
self.field_list = json.dumps([f.name for f in self.field_ids], ensure_ascii=False)
self.field_labels = json.dumps(
{f.name: f.field_description for f in self.field_ids},
ensure_ascii=False
)
# ===== create / write =====
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
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
if vals.get('field_ids'):
self._sync_field_list_from_ids(vals)
return super().create(vals_list)
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
if vals.get('field_ids'):
self._sync_field_list_from_ids(vals)
return super().write(vals)
@api.model
def _sync_field_list_from_ids(self, vals):
import json
ids = []
field_cmds = vals['field_ids']
if isinstance(field_cmds, list):
for cmd in field_cmds:
if isinstance(cmd, (list, tuple)):
if cmd[0] == 6:
ids = cmd[2]
break
elif cmd[0] == 4:
ids.append(cmd[1])
if ids:
fields_records = self.env['ir.model.fields'].browse(ids)
if not vals.get('field_list'):
vals['field_list'] = json.dumps([f.name for f in fields_records], ensure_ascii=False)
if not vals.get('field_labels'):
vals['field_labels'] = json.dumps(
{f.name: f.field_description for f in fields_records},
ensure_ascii=False
)
# ===== 固定报表:计算字段 =====
@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
# ===== 初始化列:从模型 tree 视图批量导入 =====
def action_init_columns(self):
"""从模型的默认 tree 视图中提取可见字段,批量生成列定义
- 查找模型优先级最高的 tree 视图
- 解析 arch,跳过 invisible / column_invisible / optional=hide 的字段
- 跳过系统技术字段(id, create_uid, create_date, write_uid, write_date, display_name)
- 仅添加当前 column_ids 中不存在的字段
"""
self.ensure_one()
if not self.model_name:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '无法初始化',
'message': '请先选择数据模型',
'type': 'warning',
}
}
# 1. 查找默认 tree 视图
View = self.env['ir.ui.view']
views = View.sudo().search([
('model', '=', self.model_name),
('type', '=', 'tree'),
('mode', 'in', ('primary', 'extension')),
], order='priority desc, id asc')
# 优先取 primary 视图;若无则取 extension 中第一个
tree_view = views.filtered(lambda v: v.mode == 'primary')[:1] or views[:1]
if not tree_view:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '未找到视图',
'message': f'模型 {self.model_name} 没有 tree 视图,无法自动初始化列',
'type': 'warning',
}
}
# 2. 解析 arch(处理 view inheritance)
try:
arch_et = tree_view.sudo()._get_combined_arch()
except Exception as e:
_logger.warning('解析 tree 视图 arch 失败: view=%s, error=%s', tree_view.xml_id or tree_view.id, e)
arch_et = etree.fromstring(tree_view.arch.encode('utf-8')) if tree_view.arch else None
if arch_et is None:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '解析失败',
'message': '无法解析 tree 视图的 XML 结构',
'type': 'danger',
}
}
# 3. 提取可见字段
tech_fields = {'id', 'create_uid', 'create_date', 'write_uid', 'write_date', 'display_name'}
existing_names = set(self.column_ids.mapped('field_name'))
new_columns = []
seq_base = max(self.column_ids.mapped('sequence') or [0]) + 10
# tree 中所有 <field> 节点
for idx, field_node in enumerate(arch_et.iter('field')):
fname = field_node.get('name')
if not fname:
continue
if fname in tech_fields:
continue
if fname in existing_names:
continue
# 判断是否为隐藏字段(静态属性 + modifiers JSON)
invisible = field_node.get('invisible') or field_node.get('column_invisible')
if invisible and invisible.strip() not in ('0', 'false', 'False', ''):
continue
modifiers = field_node.get('modifiers')
if modifiers:
try:
mods = json.loads(modifiers)
if mods.get('column_invisible') or mods.get('invisible'):
continue
except (json.JSONDecodeError, TypeError):
pass
if field_node.get('optional') == 'hide':
continue
# 获取显示名称:优先 arch 中的 string 属性,其次字段元数据
label = field_node.get('string')
field_rec = self.env['ir.model.fields'].sudo().search([
('model_id', '=', self.model_id.id),
('name', '=', fname),
], limit=1)
if not label:
label = field_rec.field_description if field_rec else fname
col_data = {
'field_name': fname,
'label': label,
'width': 120,
'sequence': seq_base + idx * 10,
}
if field_rec:
col_data['field_id'] = field_rec.id
new_columns.append(col_data)
if not new_columns:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '无需初始化',
'message': '所有可见字段已存在于列定义中,或该模型 tree 视图无可用字段',
'type': 'info',
}
}
# 4. 批量创建列定义
Column = self.env['ai.report.template.column']
created = 0
for col_vals in new_columns:
try:
Column.create(dict(col_vals, template_id=self.id))
created += 1
except Exception as e:
_logger.warning('创建列定义失败: template=%s, field=%s, error=%s', self.name, col_vals['field_name'], e)
# 5. 同步更新 field_list / field_labels(兼容性)
all_fields = self.column_ids.mapped('field_name')
self.field_list = json.dumps(all_fields, ensure_ascii=False)
field_labels = {}
for c in self.column_ids:
if c.field_name and c.label:
field_labels[c.field_name] = c.label
self.field_labels = json.dumps(field_labels, ensure_ascii=False)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '初始化完成',
'message': f'已从 tree 视图「{tree_view.name or tree_view.xml_id or tree_view.id}」导入 {created} 个字段到列定义',
'type': 'success',
}
}
# ===== 固定报表:生成/刷新动作 =====
def action_generate_fixed_report(self):
"""生成固定报表链接(按钮动作)
- 若 fixed_report_id 为空:创建一条不过期的 ai.report 记录,并刷新数据
- 若已存在:刷新数据(调用 report.refresh_data)
"""
self.ensure_one()
if self.fixed_report_id:
# 已存在:先更新链接再刷新数据
self.fixed_report_url_stored = self.fixed_report_id.get_report_url()
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
# ★ 先写链接再刷新数据,避免 refresh_data 内部 search_read flush 导致赋值丢失
url = report.get_report_url()
self.fixed_report_url_stored = url
# 立即刷新数据,填充 report_data 缓存
result = report.refresh_data()
if result.get('error'):
_logger.error(
'固定报表创建后刷新失败: template=%s(%s), report=%s, url=%s, error=%s',
self.name, self.id, report.id, url, result['error']
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '链接已生成但数据刷新失败',
'message': f'链接:{url}\n数据刷新失败:{result["error"]}',
'type': 'warning',
'sticky': True,
}
}
_logger.info(
'固定报表创建成功: template=%s(%s), report=%s, url=%s, count=%s',
self.name, self.id, report.id, url, result.get('count', 0)
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '固定链接已生成',
'message': f'链接:{url}\n共 {result.get("count", 0)} 条记录',
'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}