593 lines
24 KiB
Python
593 lines
24 KiB
Python
# -*- 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} |