206 lines
7.5 KiB
Python
206 lines
7.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
import json
|
||
import logging
|
||
import os
|
||
|
||
from odoo import api, fields, models
|
||
|
||
try: # Odoo 各版本导出位置略有差异,双保险
|
||
from odoo.tools import file_open
|
||
except ImportError: # pragma: no cover
|
||
from odoo.tools.misc import file_open
|
||
|
||
try: # Odoo 18 推荐用 tools.misc.file_path(get_module_resource 已弃用)
|
||
from odoo.tools.misc import file_path
|
||
except ImportError: # pragma: no cover
|
||
file_path = None
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
TEMPLATE_FILE = 'yuthon_bpd/static/src/bpd_default.json'
|
||
VIEW_FILE = 'yuthon_bpd/static/src/bpd_view.html'
|
||
|
||
|
||
class BpdDiagram(models.Model):
|
||
"""业务流程图主模型。
|
||
|
||
一张图 = 若干泳道 + 若干节点 + 若干连线。坐标全部使用画布逻辑单位,
|
||
与前端 SVG 的 viewBox 一致,因此后端只存「逻辑坐标」,渲染缩放交给前端。
|
||
"""
|
||
|
||
_name = 'yuthon.bpd.diagram'
|
||
_description = '业务流程图'
|
||
_order = 'id'
|
||
|
||
name = fields.Char(string='名称', required=True)
|
||
key = fields.Char(string='关键字', index=True)
|
||
canvas_w = fields.Integer(string='画布宽', default=1560)
|
||
canvas_h = fields.Integer(string='画布高', default=980)
|
||
theme = fields.Text(
|
||
string='主题配色',
|
||
default='{}',
|
||
help='JSON 格式配色主题,前端读取后直接映射到节点/泳道/连线',
|
||
)
|
||
|
||
lane_ids = fields.One2many('yuthon.bpd.lane', 'diagram_id', string='泳道')
|
||
node_ids = fields.One2many('yuthon.bpd.node', 'diagram_id', string='节点')
|
||
edge_ids = fields.One2many('yuthon.bpd.edge', 'diagram_id', string='连线')
|
||
|
||
# ---------------- 模板装载 ----------------
|
||
@api.model
|
||
def _ensure_default_diagram(self):
|
||
"""安装后确保存在一张示例流程图(数据来自 bpd_default.json)。"""
|
||
if self.search_count([('key', '=', 'erp_mto')]):
|
||
return None
|
||
return self._load_template(TEMPLATE_FILE)
|
||
|
||
@api.model
|
||
def _load_template(self, path):
|
||
try:
|
||
with file_open(path, 'r') as f:
|
||
data = json.load(f)
|
||
except (IOError, OSError, ValueError) as exc:
|
||
_logger.warning('加载 BPD 模板失败: %s', exc)
|
||
return None
|
||
|
||
theme = data.get('theme') or {}
|
||
diagram = self.create({
|
||
'name': data.get('name') or '业务流程图',
|
||
'key': data.get('key'),
|
||
'canvas_w': (data.get('canvas') or {}).get('width', 1560),
|
||
'canvas_h': (data.get('canvas') or {}).get('height', 980),
|
||
'theme': json.dumps(theme, ensure_ascii=False),
|
||
})
|
||
|
||
lane_map = {}
|
||
for i, l in enumerate(data.get('lanes', [])):
|
||
lane = self.env['yuthon.bpd.lane'].create({
|
||
'diagram_id': diagram.id,
|
||
'name': l.get('name', '泳道'),
|
||
'kind': l.get('kind', 'column'),
|
||
'x': l.get('x', 0),
|
||
'y': l.get('y', 0),
|
||
'w': l.get('w', 300),
|
||
'h': l.get('h', 600),
|
||
'head_h': l.get('head_h', 34),
|
||
'label_pos': l.get('label_pos', 'top-center'),
|
||
'sequence': i,
|
||
})
|
||
lane_map[l.get('name')] = lane.id
|
||
|
||
node_map = {}
|
||
for i, n in enumerate(data.get('nodes', [])):
|
||
node = self.env['yuthon.bpd.node'].create({
|
||
'diagram_id': diagram.id,
|
||
'lane_id': lane_map.get(n.get('lane')) or False,
|
||
'label': n.get('label', ''),
|
||
'sub': n.get('sub', ''),
|
||
'x': n.get('x', 0),
|
||
'y': n.get('y', 0),
|
||
'w': n.get('w', 96),
|
||
'h': n.get('h', 34),
|
||
'shape': n.get('shape', 'box'),
|
||
'variant': n.get('variant', 'doc'),
|
||
'sequence': i,
|
||
})
|
||
node_map[n.get('id')] = node.id
|
||
|
||
for e in data.get('edges', []):
|
||
frm = node_map.get(e.get('from'))
|
||
to = node_map.get(e.get('to'))
|
||
if not frm or not to:
|
||
continue
|
||
self.env['yuthon.bpd.edge'].create({
|
||
'diagram_id': diagram.id,
|
||
'from_id': frm,
|
||
'to_id': to,
|
||
'label': e.get('label', ''),
|
||
'style': e.get('style', 'solid'),
|
||
})
|
||
|
||
_logger.info('已装载 BPD 示例流程图 key=%s', data.get('key'))
|
||
return diagram
|
||
|
||
# ---------------- 序列化 ----------------
|
||
def to_json(self):
|
||
self.ensure_one()
|
||
try:
|
||
theme = json.loads(self.theme or '{}') if self.theme else {}
|
||
except (ValueError, TypeError):
|
||
theme = {}
|
||
return {
|
||
'id': self.id,
|
||
'name': self.name,
|
||
'key': self.key or '',
|
||
'canvas': {'width': self.canvas_w, 'height': self.canvas_h},
|
||
'theme': theme,
|
||
'lanes': [{
|
||
'id': l.id,
|
||
'name': l.name,
|
||
'kind': l.kind,
|
||
'x': l.x, 'y': l.y, 'w': l.w, 'h': l.h,
|
||
'head_h': l.head_h,
|
||
'label_pos': l.label_pos,
|
||
} for l in self.lane_ids],
|
||
'nodes': [n.to_json() for n in self.node_ids],
|
||
'edges': [e.to_json() for e in self.edge_ids],
|
||
}
|
||
|
||
@api.model
|
||
def get_default(self):
|
||
"""前端拿图:优先 key=erp_mto,否则取第一张。"""
|
||
rec = self.search([('key', '=', 'erp_mto')], limit=1) \
|
||
or self.search([], limit=1, order='id')
|
||
return rec.to_json() if rec else None
|
||
|
||
# ---------------- 入口:后台打开前端 ----------------
|
||
@api.model
|
||
def view_asset_version(self):
|
||
"""前端静态页的版本号(取文件 mtime)。
|
||
|
||
bpd_view.html 的响应头是 Cache-Control: max-age=604800(7 天),
|
||
浏览器可能一直用旧副本。拿它拼 ?v=... 可打破缓存。
|
||
"""
|
||
if not file_path:
|
||
return 0
|
||
try:
|
||
return int(os.path.getmtime(file_path(VIEW_FILE, filter_ext=('.html',))))
|
||
except (OSError, ValueError):
|
||
return 0
|
||
|
||
@api.model
|
||
def refresh_visual_action_params(self):
|
||
"""升级模块时把最新版本号写进 client action 的 params(供菜单入口用)。
|
||
|
||
数据 XML 里用 <function> 调用,故不受 noupdate 影响,每次升级都会刷新。
|
||
"""
|
||
action = self.env.ref('yuthon_bpd.yuthon_bpd_action_visual',
|
||
raise_if_not_found=False)
|
||
if action:
|
||
action.params = {'v': self.view_asset_version()}
|
||
|
||
def action_open_visual(self):
|
||
"""后台表单按钮:在 Odoo 内部打开该图的可视化视图(不再跳走)。
|
||
|
||
返回客户端动作 yuthon_bpd.visual,由前端 OWL 组件用 iframe 承载
|
||
bpd_view.html 并撑满内容区;顶部面包屑可返回原表单。
|
||
"""
|
||
self.ensure_one()
|
||
return {
|
||
'type': 'ir.actions.client',
|
||
'tag': 'yuthon_bpd.visual',
|
||
'name': self.name,
|
||
'params': {'diagram_id': self.id, 'v': self.view_asset_version()},
|
||
'target': 'current',
|
||
'context': dict(self.env.context, active_id=self.id, active_model=self._name),
|
||
}
|
||
|
||
def action_open_visual_url(self):
|
||
"""后台表单按钮:在新标签打开独立页面(多屏对照时用)。"""
|
||
self.ensure_one()
|
||
return {
|
||
'type': 'ir.actions.act_url',
|
||
'url': '/yuthon_bpd/static/src/bpd_view.html?id=%s' % self.id,
|
||
'target': 'new',
|
||
}
|