This commit is contained in:
李鹏宇
2026-06-05 14:40:25 +08:00
parent f01fdf5588
commit b68c368d4c
+224 -87
View File
@@ -4,7 +4,8 @@ import json
import logging
import re
from odoo import models, fields, api
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from datetime import timedelta
_logger = logging.getLogger(__name__)
@@ -20,17 +21,17 @@ class AiReportTemplate(models.Model):
model_name = fields.Char('数据模型', required=True,
help='Odoo 模型技术名称,如 sale.order')
model_id = fields.Many2one('ir.model', string='数据模型', ondelete='cascade',
help='选择数据模型,选择后自动填充"数据模型"字段')
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='选择要显示的字段。选择后自动填充"字段列表"和"字段标签"')
'template_id', 'field_id', string='显示字段',
domain="[('model_id','=',model_id)]",
help='选择要显示的字段。选择后自动填充"字段列表"和"字段标签"')
default_domain = fields.Char('默认查询条件', default="[]",
help='Odoo domain 格式,如 [("state","=","sale"),("amount_total",">",1000)]')
help='Odoo domain 格式,如 [("state","=","sale"),("amount_total",">",1000)]')
default_order = fields.Char('默认排序', default='id desc')
description = fields.Text('使用说明', translate=True,
help='告诉 AI 何时使用此模板')
@@ -39,33 +40,34 @@ class AiReportTemplate(models.Model):
# ===== 视觉配置(拆分为 One2many) =====
column_ids = fields.One2many('ai.report.template.column', 'template_id',
string='列定义', copy=True)
string='列定义', copy=True)
card_ids = fields.One2many('ai.report.template.card', 'template_id',
string='汇总卡片', copy=True)
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)]")
'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)]")
'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='列定义中已配置的字段,用于限制分组/筛选器的可选范围')
string='列定义字段集合',
compute='_compute_column_field_ids',
help='列定义中已配置的字段,用于限制分组/筛选器的可选范围')
param_def_ids = fields.One2many('ai.report.template.param.def', 'template_id',
string='AI 参数定义', copy=True)
string='AI 参数定义', 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')
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')
compute='_compute_param_definitions', inverse='_inverse_param_definitions',
store=True,
help='自动从参数定义行生成,也可手动编辑 JSON')
# ===== Computed: column_field_ids =====
@api.depends('column_ids.field_id')
@@ -210,7 +212,7 @@ class AiReportTemplate(models.Model):
param_defs = json.loads(t.param_definitions)
if param_defs:
param_info = ',参数: ' + ', '.join([
f'{p.get("name","")}({p.get("label","")}:{p.get("type","")})'
f'{p.get("name", "")}({p.get("label", "")}:{p.get("type", "")})'
for p in param_defs
])
base_info += param_info
@@ -225,108 +227,243 @@ class AiReportTemplate(models.Model):
if self.model_id:
self.model_name = self.model_id.model
# ===== field_ids 变化 → 联动 field_list / field_labels / column_ids =====
@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_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)
else:
self.field_list = '[]'
self.field_labels = '{}'
self._sync_columns()
ensure_ascii=False
)
# 同步 column_ids:去重 + 删不在的 + 补缺失的 + 修复空字段
self._sync_column_ids_from_field_ids()
# ===== 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 'field_ids' in vals:
if vals.get('field_ids'):
self._sync_field_list_from_ids(vals)
records = super().create(vals_list)
# 使用 sudo 避免权限问题,使用 with_context 避免约束检查冲突
for record in records:
record._sync_columns()
if record.field_ids:
record.with_context(sync_columns=True)._sync_column_ids_from_field_ids()
return records
def write(self, vals):
# 处理 model_id 变化
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
changed = 'field_ids' in vals
if changed:
# 处理 field_ids 变化
if vals.get('field_ids'):
self._sync_field_list_from_ids(vals)
result = super().write(vals)
if changed:
for record in self:
record._sync_columns()
# 同步 column_ids(使用 context 标记避免循环)
for record in self:
if record.field_ids and not self.env.context.get('syncing_columns'):
record.with_context(syncing_columns=True)._sync_column_ids_from_field_ids()
return result
def _sync_columns(self):
"""显示字段多了 → 列定义跟着加;显示字段少了 → 列定义跟着删"""
def _sync_column_ids_from_field_ids(self):
"""
根据 field_ids 同步 column_ids:
1. 删除不在 field_ids 中的列
2. 添加 field_ids 中缺失的列
3. 清理重复的列
4. 修复空 field_name
使用安全的删除策略避免约束冲突
"""
self.ensure_one()
# 现有列按 field_id 索引(重复取第一条,多余的记下来删掉)
col_by_fid = {}
trash = []
if not self.field_ids:
# 如果没有选择字段,清空所有列定义
if self.column_ids:
self.column_ids.unlink()
return
new_field_ids = set(self.field_ids.ids)
# 第一步:收集所有有效的列定义
valid_columns = {}
columns_to_remove = self.env['ai.report.template.column']
for col in self.column_ids:
fid = col.field_id.id if col.field_id else 0
fid = col.field_id.id if col.field_id else None
# 没有关联 field_id 的列直接标记删除
if not fid:
trash.append(col.id)
elif fid in col_by_fid:
trash.append(col.id)
columns_to_remove |= col
continue
# 如果字段不在 field_ids 中,标记删除
if fid not in new_field_ids:
columns_to_remove |= col
continue
# 处理重复:同一个 field_id 只保留第一个
if fid in valid_columns:
columns_to_remove |= col
else:
col_by_fid[fid] = col
valid_columns[fid] = col
field_ids = set(self.field_ids.ids)
cmds = [(3, cid) for cid in trash]
# 第二步:安全删除无效的列(使用 unlink 而不是命令删除)
if columns_to_remove:
try:
columns_to_remove.unlink()
except Exception as e:
_logger.warning(f"删除列定义失败: {e}")
# 如果直接删除失败,尝试逐个删除
for col in columns_to_remove:
try:
col.unlink()
except Exception as e2:
_logger.warning(f"删除列 {col.id} 失败: {e2}")
# 应该保留的列 → (4, id) 告诉 Odoo "这条别删"
for fid, col in col_by_fid.items():
if fid in field_ids:
cmds.append((4, col.id))
# 第三步:更新保留的列,确保 field_name 不为空
for fid, col in valid_columns.items():
if not col.field_name:
field = self.field_ids.filtered(lambda r: r.id == fid)
if field:
col.field_name = field.name
# 显示字段有、列定义没有 → 加
seq = max((c.sequence for c in self.column_ids if c.sequence), default=0) + 10
for f in self.field_ids:
if f.id not in col_by_fid:
cmds.append((0, 0, {
'field_id': f.id,
'field_name': f.name,
'label': f.field_description,
'sequence': seq,
}))
seq += 10
# 第四步:添加缺失的字段
existing_fids = set(valid_columns.keys())
missing_fids = new_field_ids - existing_fids
# 列定义有、显示字段没有 → 删
for fid, col in col_by_fid.items():
if fid not in field_ids:
cmds.append((3, col.id))
if missing_fids:
# 计算新的序列值
max_seq = max([col.sequence for col in self.column_ids], default=0)
seq = max_seq + 10
if cmds:
self.column_ids = cmds
for fid in missing_fids:
field = self.field_ids.filtered(lambda r: r.id == fid)
if field:
# 直接创建新的 column 记录
self.env['ai.report.template.column'].create({
'template_id': self.id,
'field_id': fid,
'field_name': field.name,
'label': field.field_description,
'sequence': seq,
'width': 120,
})
seq += 10
# 第五步:清理重复(如果有重复的 field_name,保留一个)
self._cleanup_duplicate_columns()
def _cleanup_duplicate_columns(self):
"""
清理重复的列定义(基于 field_name)
"""
self.ensure_one()
field_name_map = {}
duplicates = self.env['ai.report.template.column']
for col in self.column_ids:
if not col.field_name:
continue
if col.field_name in field_name_map:
duplicates |= col
else:
field_name_map[col.field_name] = col
if duplicates:
try:
duplicates.unlink()
except Exception as e:
_logger.warning(f"清理重复列失败: {e}")
@api.model
def _sync_field_list_from_ids(self, vals):
"""从 field_ids 同步 field_list 和 field_labels"""
import json
ids = []
for cmd in (vals['field_ids'] if isinstance(vals['field_ids'], list) else []):
if isinstance(cmd, (list, tuple)):
if cmd[0] == 6:
ids = list(cmd[2]) if cmd[2] else []
break
elif cmd[0] == 4:
ids.append(cmd[1])
elif cmd[0] == 5:
ids = []
break
field_cmds = vals.get('field_ids', [])
# 解析命令格式
if isinstance(field_cmds, list):
for cmd in field_cmds:
if isinstance(cmd, (list, tuple)):
if cmd[0] == 6: # replace all
ids = cmd[2]
break
elif cmd[0] == 4: # add
ids.append(cmd[1])
elif cmd[0] == 5: # remove all
ids = []
break
elif cmd[0] == 3: # remove
# 需要从现有记录中移除,这里简化处理
pass
elif isinstance(field_cmds, int):
ids = [field_cmds]
if ids:
fields = self.env['ir.model.fields'].browse(ids)
vals['field_list'] = json.dumps([f.name for f in fields], ensure_ascii=False)
vals['field_labels'] = json.dumps(
{f.name: f.field_description for f in fields}, ensure_ascii=False)
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
)
else:
vals['field_list'] = '[]'
vals['field_labels'] = '{}'
# 如果没有字段,清空
if not vals.get('field_list'):
vals['field_list'] = '[]'
if not vals.get('field_labels'):
vals['field_labels'] = '{}'
class AiReportTemplateColumn(models.Model):
_name = 'ai.report.template.column'
_description = '报表模板列定义'
_order = 'sequence, id'
template_id = fields.Many2one('ai.report.template', string='模板', required=True, ondelete='cascade')
field_id = fields.Many2one('ir.model.fields', string='字段',
domain="[('model_id', '=', template_id.model_id)]")
field_name = fields.Char('字段名', required=True) # 改为必填
label = fields.Char('显示名称')
width = fields.Integer('宽度', default=120)
sequence = fields.Integer('排序', default=10)
# 颜色配置
cell_color = fields.Char('单元格颜色', help='十六进制颜色值,如 #FF0000')
color_operator = fields.Selection([
('eq', '等于'),
('neq', '不等于'),
('gt', '大于'),
('lt', '小于'),
('gte', '大于等于'),
('lte', '小于等于'),
('contains', '包含'),
], string='条件运算符', default='eq')
color_value = fields.Char('条件值')
@api.constrains('field_name')
def _check_field_name(self):
"""确保 field_name 不为空"""
for record in self:
if not record.field_name:
raise ValidationError(_("列定义的字段名不能为空"))
@api.onchange('field_id')
def _onchange_field_id(self):
"""当选择字段时自动填充字段名和标签"""
if self.field_id:
self.field_name = self.field_id.name
if not self.label:
self.label = self.field_id.field_description