diff --git a/yuthon_ai_agent/models/ai_report_template.py b/yuthon_ai_agent/models/ai_report_template.py index 4c8531a3f..d9eaf5ac9 100644 --- a/yuthon_ai_agent/models/ai_report_template.py +++ b/yuthon_ai_agent/models/ai_report_template.py @@ -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 \ No newline at end of file