Merge pull request '流程图' (#3) from dev_wang into master

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
wanghaoyang
2026-09-22 15:10:06 +08:00
15 changed files with 1601 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
def post_init_hook(env):
"""安装后自动建一张示例流程图(数据来自 static/src/bpd_default.json)。"""
env['yuthon.bpd.diagram']._ensure_default_diagram()
+18
View File
@@ -0,0 +1,18 @@
{
'name': 'Yuthon BPD 业务流程图',
'summary': '泳道式业务流程图(可拖拽新建关联流程)',
'version': '18.0.1.0.0',
'category': 'Tools',
'license': 'OPL-1',
'depends': ['base', 'web'],
'data': [
'security/ir.model.access.csv',
# 注意顺序:menu.xml 里的 <menuitem action="..."> 会在解析时立即
# env.ref() 解析动作的 xmlid,因此定义动作的 bpd_views.xml 必须在前。
'views/bpd_views.xml',
'views/menu.xml',
],
'post_init_hook': 'post_init_hook',
'installable': True,
'application': True,
}
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import main
+197
View File
@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
import json
from odoo import http
from odoo.exceptions import AccessError
from odoo.http import request, Response
def _json(data, status=200):
return Response(json.dumps(data, ensure_ascii=False),
mimetype='application/json', status=status)
def _body():
try:
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
except (ValueError, TypeError):
return {}
class BpdController(http.Controller):
"""业务流程图前端的数据接口。
读图对所有登录用户开放;新建节点 / 连线 / 移动 / 改名 / 删除 等写操作
只有系统管理员有 write/create 权限(权限由 ir.model.access.csv 控制),
普通用户会收到 403,前端据此提示「需管理员权限」。
"""
# ---------------- 只读:拿单张图 / 图列表 ----------------
@http.route('/yuthon_bpd/api/diagram/data', type='http',
auth='user', methods=['GET'], csrf=False)
def api_diagram_data(self, **kw):
"""不带 id:返回默认图(key=erp_mto);带 ?id=N:返回指定图。"""
Diagram = request.env['yuthon.bpd.diagram']
did = kw.get('id')
if did not in (None, '', '0'):
try:
did = int(did)
except (TypeError, ValueError):
return _json({'error': 'bad_id'}, 400)
rec = Diagram.browse(did).exists()
if not rec:
return _json({'error': 'not_found'}, 404)
return _json({'diagram': rec.to_json()})
data = Diagram.get_default()
if not data:
return _json({'error': 'no_diagram'}, 404)
return _json({'diagram': data})
@http.route('/yuthon_bpd/api/diagram/list', type='http',
auth='user', methods=['GET'], csrf=False)
def api_diagram_list(self, **kw):
"""供前端下拉切换:返回所有流程图的 id / 名称 / key。"""
recs = request.env['yuthon.bpd.diagram'].search([], order='id')
return _json({'diagrams': [
{'id': r.id, 'name': r.name, 'key': r.key or ''} for r in recs
]})
# ---------------- 写:节点 ----------------
@http.route('/yuthon_bpd/api/diagram/<int:did>/node', type='http',
auth='user', methods=['POST'], csrf=False)
def api_create_node(self, did, **kw):
diagram = request.env['yuthon.bpd.diagram'].browse(did).exists()
if not diagram:
return _json({'error': 'not_found'}, 404)
p = _body()
label = (p.get('label') or '').strip()[:120]
if not label:
return _json({'error': 'empty_label'}, 400)
lane_id = p.get('lane_id') or False
try:
node = request.env['yuthon.bpd.node'].create({
'diagram_id': did,
'lane_id': int(lane_id) if lane_id else False,
'label': label,
'sub': (p.get('sub') or '').strip()[:60],
'x': int(p.get('x', 0) or 0),
'y': int(p.get('y', 0) or 0),
'w': int(p.get('w', 96) or 96),
'h': int(p.get('h', 34) or 34),
'shape': p.get('shape') or 'box',
'variant': p.get('variant') or 'doc',
})
except AccessError:
return _json({'error': 'forbidden'}, 403)
except Exception as exc:
if 'unique' in str(exc).lower():
return _json({'error': 'duplicate'}, 409)
raise
return _json({'node': node.to_json()})
@http.route('/yuthon_bpd/api/node/<int:nid>/move', type='http',
auth='user', methods=['POST'], csrf=False)
def api_move_node(self, nid, **kw):
node = request.env['yuthon.bpd.node'].browse(nid).exists()
if not node:
return _json({'error': 'not_found'}, 404)
p = _body()
try:
node.write({
'x': int(p.get('x', node.x)),
'y': int(p.get('y', node.y)),
})
except AccessError:
return _json({'error': 'forbidden'}, 403)
return _json({'ok': True})
@http.route('/yuthon_bpd/api/node/<int:nid>/rename', type='http',
auth='user', methods=['POST'], csrf=False)
def api_rename_node(self, nid, **kw):
node = request.env['yuthon.bpd.node'].browse(nid).exists()
if not node:
return _json({'error': 'not_found'}, 404)
p = _body()
label = (p.get('label') or '').strip()[:120]
if not label:
return _json({'error': 'empty_label'}, 400)
try:
node.write({'label': label})
except AccessError:
return _json({'error': 'forbidden'}, 403)
return _json({'ok': True, 'label': node.label})
# ---------------- 写:连线 ----------------
@http.route('/yuthon_bpd/api/diagram/<int:did>/edge', type='http',
auth='user', methods=['POST'], csrf=False)
def api_create_edge(self, did, **kw):
diagram = request.env['yuthon.bpd.diagram'].browse(did).exists()
if not diagram:
return _json({'error': 'not_found'}, 404)
p = _body()
try:
frm = int(p.get('from'))
to = int(p.get('to'))
except (TypeError, ValueError):
return _json({'error': 'bad_params'}, 400)
if frm == to:
return _json({'error': 'same_node'}, 400)
Node = request.env['yuthon.bpd.node']
if not (Node.browse(frm).exists() and Node.browse(to).exists()):
return _json({'error': 'node_missing'}, 404)
try:
edge = request.env['yuthon.bpd.edge'].create({
'diagram_id': did,
'from_id': frm,
'to_id': to,
'label': (p.get('label') or '').strip()[:60],
'style': p.get('style') or 'solid',
})
except AccessError:
return _json({'error': 'forbidden'}, 403)
except Exception as exc:
if 'unique' in str(exc).lower():
return _json({'error': 'duplicate'}, 409)
raise
return _json({'edge': edge.to_json()})
@http.route('/yuthon_bpd/api/edge/<int:eid>/delete', type='http',
auth='user', methods=['POST'], csrf=False)
def api_delete_edge(self, eid, **kw):
edge = request.env['yuthon.bpd.edge'].browse(eid).exists()
if not edge:
return _json({'error': 'not_found'}, 404)
try:
edge.unlink()
except AccessError:
return _json({'error': 'forbidden'}, 403)
return _json({'ok': True})
# ---------------- 写:泳道 ----------------
@http.route('/yuthon_bpd/api/diagram/<int:did>/lane', type='http',
auth='user', methods=['POST'], csrf=False)
def api_create_lane(self, did, **kw):
diagram = request.env['yuthon.bpd.diagram'].browse(did).exists()
if not diagram:
return _json({'error': 'not_found'}, 404)
p = _body()
name = (p.get('name') or '新泳道').strip()[:60]
try:
lane = request.env['yuthon.bpd.lane'].create({
'diagram_id': did,
'name': name,
'kind': p.get('kind') or 'column',
'x': int(p.get('x', 24) or 24),
'y': int(p.get('y', 86) or 86),
'w': int(p.get('w', 300) or 300),
'h': int(p.get('h', 600) or 600),
'head_h': int(p.get('head_h', 34) or 34),
'label_pos': p.get('label_pos') or 'top-center',
})
except AccessError:
return _json({'error': 'forbidden'}, 403)
return _json({'lane': {
'id': lane.id, 'name': lane.name, 'kind': lane.kind,
'x': lane.x, 'y': lane.y, 'w': lane.w, 'h': lane.h,
'head_h': lane.head_h, 'label_pos': lane.label_pos,
}})
+5
View File
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import bpd_diagram
from . import bpd_lane
from . import bpd_node
from . import bpd_edge
+157
View File
@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
import json
import logging
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
_logger = logging.getLogger(__name__)
TEMPLATE_FILE = 'yuthon_bpd/static/src/bpd_default.json'
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
# ---------------- 入口:后台跳前端 ----------------
def action_open_visual(self):
"""后台表单按钮:在新标签打开该图的可视化编辑页(带 diagram id)。"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': '/yuthon_bpd/static/src/bpd_view.html?id=%s' % self.id,
'target': 'new',
}
+41
View File
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class BpdEdge(models.Model):
"""流程图连线(流程走向 / 关联流程)。"""
_name = 'yuthon.bpd.edge'
_description = '流程连线'
_order = 'sequence, id'
diagram_id = fields.Many2one(
'yuthon.bpd.diagram', string='流程图', required=True,
ondelete='cascade', index=True,
)
from_id = fields.Many2one(
'yuthon.bpd.node', string='起点', required=True, ondelete='cascade',
)
to_id = fields.Many2one(
'yuthon.bpd.node', string='终点', required=True, ondelete='cascade',
)
label = fields.Char(string='线标签')
style = fields.Selection(
[('solid', '实线'), ('dashed', '虚线')],
string='线型', default='solid', required=True,
)
sequence = fields.Integer(default=10)
_sql_constraints = [
('bpd_edge_uniq', 'unique(diagram_id, from_id, to_id)',
'同一条流程连线不能重复'),
]
def to_json(self):
self.ensure_one()
return {
'id': self.id,
'from': self.from_id.id,
'to': self.to_id.id,
'label': self.label or '',
'style': self.style,
}
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class BpdLane(models.Model):
"""流程图泳道 / 分组框。
泳道就是画布上的一个矩形,坐标用画布逻辑单位(默认 1560 x 980)。
这样参考图那种"手工排版的泳道图"可以被完整表达,而不必受自动布局约束。
"""
_name = 'yuthon.bpd.lane'
_description = '流程图泳道'
_order = 'sequence, id'
_rec_name = 'name'
diagram_id = fields.Many2one(
'yuthon.bpd.diagram', string='流程图', required=True,
ondelete='cascade', index=True,
)
name = fields.Char(string='名称', required=True)
kind = fields.Selection(
[('column', '纵向泳道'), ('band', '横向泳道'), ('group', '分组框')],
string='类型', default='column', required=True,
help='纵向泳道 = 一列(如销售管理);横向泳道 = 一条横带(如仓库管理、财务管理)',
)
x = fields.Integer(string='X', default=0, help='矩形左上角 X(画布逻辑单位)')
y = fields.Integer(string='Y', default=0, help='矩形左上角 Y(画布逻辑单位)')
w = fields.Integer(string='宽', default=300)
h = fields.Integer(string='高', default=600)
head_h = fields.Integer(
string='标题高', default=34,
help='仅纵向泳道使用:顶部标题框的高度;横向泳道标题直接写在带子里',
)
label_pos = fields.Selection(
[('top-center', '顶部居中'), ('bottom-center', '底部居中'), ('left', '左侧')],
string='标题位置', default='top-center',
)
sequence = fields.Integer(default=10)
note = fields.Char(string='备注')
node_ids = fields.One2many('yuthon.bpd.node', 'lane_id', string='节点')
node_count = fields.Integer(string='节点数', compute='_compute_node_count')
def _compute_node_count(self):
for lane in self:
lane.node_count = len(lane.node_ids)
+61
View File
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class BpdNode(models.Model):
"""流程图节点(一个业务动作 / 单据 / 角色)。
x / y 是节点**中心**坐标,单位与泳道一致。拖动节点时前端只改这两个值,
所以画布上任何手工微调都能持久化。
"""
_name = 'yuthon.bpd.node'
_description = '流程节点'
_order = 'sequence, id'
_rec_name = 'label'
diagram_id = fields.Many2one(
'yuthon.bpd.diagram', string='流程图', required=True,
ondelete='cascade', index=True,
)
lane_id = fields.Many2one(
'yuthon.bpd.lane', string='所属泳道', ondelete='set null', index=True,
)
label = fields.Char(string='名称', required=True)
sub = fields.Char(string='副标题', help='节点下方的小字,如「批次」')
x = fields.Integer(string='X', default=0)
y = fields.Integer(string='Y', default=0)
w = fields.Integer(string='宽', default=96)
h = fields.Integer(string='高', default=34)
shape = fields.Selection(
[('box', '矩形'), ('diamond', '菱形'), ('pill', '圆角胶囊'), ('text', '纯文字')],
string='形状', default='box', required=True,
)
variant = fields.Selection(
[
('doc', '普通单据'),
('start', '起点 / 外部角色'),
('primary', '核心(仓库 / 财务)'),
('gateway', '判断(核销)'),
('ghost', '虚化(任务池)'),
],
string='样式', default='doc', required=True,
)
sequence = fields.Integer(default=10)
note = fields.Text(string='说明')
edge_out_ids = fields.One2many('yuthon.bpd.edge', 'from_id', string='出线')
edge_in_ids = fields.One2many('yuthon.bpd.edge', 'to_id', string='入线')
def to_json(self):
self.ensure_one()
return {
'id': self.id,
'lane_id': self.lane_id.id or None,
'lane': self.lane_id.name or '',
'label': self.label or '',
'sub': self.sub or '',
'x': self.x, 'y': self.y, 'w': self.w, 'h': self.h,
'shape': self.shape,
'variant': self.variant,
'note': self.note or '',
}
+9
View File
@@ -0,0 +1,9 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yuthon_bpd_diagram_admin,yuthon.bpd.diagram.admin,model_yuthon_bpd_diagram,base.group_system,1,1,1,1
access_yuthon_bpd_diagram_user,yuthon.bpd.diagram.user,model_yuthon_bpd_diagram,base.group_user,1,0,0,0
access_yuthon_bpd_lane_admin,yuthon.bpd.lane.admin,model_yuthon_bpd_lane,base.group_system,1,1,1,1
access_yuthon_bpd_lane_user,yuthon.bpd.lane.user,model_yuthon_bpd_lane,base.group_user,1,0,0,0
access_yuthon_bpd_node_admin,yuthon.bpd.node.admin,model_yuthon_bpd_node,base.group_system,1,1,1,1
access_yuthon_bpd_node_user,yuthon.bpd.node.user,model_yuthon_bpd_node,base.group_user,1,0,0,0
access_yuthon_bpd_edge_admin,yuthon.bpd.edge.admin,model_yuthon_bpd_edge,base.group_system,1,1,1,1
access_yuthon_bpd_edge_user,yuthon.bpd.edge.user,model_yuthon_bpd_edge,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yuthon_bpd_diagram_admin yuthon.bpd.diagram.admin model_yuthon_bpd_diagram base.group_system 1 1 1 1
3 access_yuthon_bpd_diagram_user yuthon.bpd.diagram.user model_yuthon_bpd_diagram base.group_user 1 0 0 0
4 access_yuthon_bpd_lane_admin yuthon.bpd.lane.admin model_yuthon_bpd_lane base.group_system 1 1 1 1
5 access_yuthon_bpd_lane_user yuthon.bpd.lane.user model_yuthon_bpd_lane base.group_user 1 0 0 0
6 access_yuthon_bpd_node_admin yuthon.bpd.node.admin model_yuthon_bpd_node base.group_system 1 1 1 1
7 access_yuthon_bpd_node_user yuthon.bpd.node.user model_yuthon_bpd_node base.group_user 1 0 0 0
8 access_yuthon_bpd_edge_admin yuthon.bpd.edge.admin model_yuthon_bpd_edge base.group_system 1 1 1 1
9 access_yuthon_bpd_edge_user yuthon.bpd.edge.user model_yuthon_bpd_edge base.group_user 1 0 0 0
+133
View File
@@ -0,0 +1,133 @@
{
"key": "erp_mto",
"name": "ERP 业务流程(离散制造-MTO)",
"title": "ERP(离散制造-MTO)",
"canvas": { "width": 1560, "height": 980 },
"theme": {
"pageBg": "#EFF7FD",
"canvasBg": "#FFFFFF",
"canvasBorder": "#CFE6F5",
"bannerFrom": "#1DA48E",
"bannerTo": "#31C3AA",
"laneBorder": "#8ADACB",
"laneHeadBorder": "#6ACDBB",
"laneHeadText": "#0F9E87",
"bandBg": "#F0FBF8",
"bandText": "#0F9E87",
"nodeFill": "#E8F2FB",
"nodeBorder": "#A9CFE9",
"nodeText": "#5B7B94",
"nodeSubText": "#93AFC3",
"startFill": "#EAF4FD",
"primaryFill": "#C6E1F8",
"primaryBorder": "#93C6EA",
"primaryText": "#2F6D92",
"ghostFill": "#F5FAFE",
"ghostBorder": "#D5E8F5",
"ghostText": "#A2BCCE",
"gatewayFill": "#E5F6F1",
"gatewayBorder": "#98D3C2",
"gatewayText": "#3E8F81",
"flow": "#4FC0A8"
},
"lanes": [
{ "name": "销售管理", "kind": "column", "x": 24, "y": 86, "w": 326, "h": 804, "label_pos": "top-center" },
{ "name": "技术管理", "kind": "column", "x": 350, "y": 86, "w": 223, "h": 804, "label_pos": "top-center" },
{ "name": "生产管理", "kind": "column", "x": 573, "y": 86, "w": 725, "h": 804, "label_pos": "top-center" },
{ "name": "采购管理", "kind": "column", "x": 1298, "y": 86, "w": 238, "h": 804, "label_pos": "top-center" },
{ "name": "仓库管理", "kind": "band", "x": 573, "y": 470, "w": 725, "h": 140, "label_pos": "bottom-center" },
{ "name": "财务管理", "kind": "band", "x": 24, "y": 906, "w": 1512, "h": 62, "label_pos": "bottom-center" }
],
"nodes": [
{ "id": "n_customer", "lane": "销售管理", "label": "客户", "x": 235, "y": 130, "w": 96, "h": 34, "variant": "start" },
{ "id": "n_quote", "lane": "销售管理", "label": "报价单", "x": 235, "y": 196, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_so", "lane": "销售管理", "label": "销售订单", "x": 235, "y": 262, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_deliver", "lane": "销售管理", "label": "销售出库/退货", "sub": "批次", "x": 235, "y": 372, "w": 116, "h": 34, "variant": "doc" },
{ "id": "n_ar", "lane": "销售管理", "label": "应收账款明细", "x": 235, "y": 690, "w": 120, "h": 34, "variant": "doc" },
{ "id": "n_bill", "lane": "销售管理", "label": "开票", "x": 124, "y": 772, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_recon1", "lane": "销售管理", "label": "对账", "x": 235, "y": 772, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_bad", "lane": "销售管理", "label": "坏账发票", "x": 235, "y": 840, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_ver1", "lane": "财务管理", "label": "收款单核销", "x": 235, "y": 937, "w": 110, "h": 42, "variant": "gateway", "shape": "diamond" },
{ "id": "n_design", "lane": "技术管理", "label": "方案设计", "x": 388, "y": 229, "w": 104, "h": 34, "variant": "doc" },
{ "id": "n_route", "lane": "技术管理", "label": "生产工序", "x": 508, "y": 160, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_product", "lane": "技术管理", "label": "产品信息", "x": 508, "y": 229, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_bom", "lane": "技术管理", "label": "产品BOM", "x": 508, "y": 298, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_shop", "lane": "生产管理", "label": "车间", "x": 660, "y": 130, "w": 96, "h": 34, "variant": "start" },
{ "id": "n_plan", "lane": "生产管理", "label": "生产计划", "x": 639, "y": 328, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_pool", "lane": "生产管理", "label": "生产任务池", "x": 774, "y": 238, "w": 100, "h": 30, "variant": "ghost" },
{ "id": "n_wo", "lane": "生产管理", "label": "生产工单", "sub": "批次", "x": 774, "y": 328, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_issue", "lane": "生产管理", "label": "生产领/退料", "x": 919, "y": 328, "w": 120, "h": 34, "variant": "doc" },
{ "id": "n_qc", "lane": "生产管理", "label": "生产报工/质检", "x": 1080, "y": 328, "w": 132, "h": 34, "variant": "doc" },
{ "id": "n_receipt", "lane": "生产管理", "label": "生产入库", "x": 1231, "y": 328, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_wh", "lane": "仓库管理", "label": "仓库", "x": 838, "y": 400, "w": 100, "h": 34, "variant": "primary" },
{ "id": "n_out_oth", "lane": "仓库管理", "label": "其他出库单", "x": 660, "y": 555, "w": 110, "h": 34, "variant": "doc" },
{ "id": "n_transfer", "lane": "仓库管理", "label": "库存调拨", "x": 838, "y": 555, "w": 110, "h": 34, "variant": "doc" },
{ "id": "n_count", "lane": "仓库管理", "label": "库存盘点", "x": 1080, "y": 555, "w": 110, "h": 34, "variant": "doc" },
{ "id": "n_in_oth", "lane": "仓库管理", "label": "其他入库单", "x": 1226, "y": 555, "w": 110, "h": 34, "variant": "doc" },
{ "id": "n_vendor", "lane": "采购管理", "label": "供应商", "x": 1360, "y": 130, "w": 96, "h": 34, "variant": "start" },
{ "id": "n_preq", "lane": "采购管理", "label": "采购申请", "x": 1360, "y": 196, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_po", "lane": "采购管理", "label": "采购订单", "x": 1360, "y": 262, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_grn", "lane": "采购管理", "label": "采购入库/退货", "sub": "批次", "x": 1360, "y": 372, "w": 116, "h": 34, "variant": "doc" },
{ "id": "n_ap", "lane": "采购管理", "label": "应付账款明细", "x": 1360, "y": 690, "w": 120, "h": 34, "variant": "doc" },
{ "id": "n_recon2", "lane": "采购管理", "label": "对账", "x": 1350, "y": 772, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_getinv", "lane": "采购管理", "label": "收票", "x": 1478, "y": 772, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_ininv", "lane": "采购管理", "label": "进项发票", "x": 1350, "y": 840, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_ver2", "lane": "财务管理", "label": "付款单核销", "x": 1430, "y": 937, "w": 110, "h": 42, "variant": "gateway", "shape": "diamond" },
{ "id": "n_cash", "lane": "财务管理", "label": "收款单", "x": 520, "y": 937, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_fin", "lane": "财务管理", "label": "财务", "x": 790, "y": 937, "w": 120, "h": 34, "variant": "primary" },
{ "id": "n_pay", "lane": "财务管理", "label": "付款单", "x": 1050, "y": 937, "w": 96, "h": 34, "variant": "doc" }
],
"edges": [
{ "from": "n_customer", "to": "n_quote" },
{ "from": "n_quote", "to": "n_so" },
{ "from": "n_so", "to": "n_deliver" },
{ "from": "n_deliver", "to": "n_ar" },
{ "from": "n_ar", "to": "n_bill" },
{ "from": "n_ar", "to": "n_recon1" },
{ "from": "n_bill", "to": "n_bad" },
{ "from": "n_recon1", "to": "n_bad" },
{ "from": "n_bad", "to": "n_ver1" },
{ "from": "n_deliver", "to": "n_wh" },
{ "from": "n_so", "to": "n_plan", "style": "dashed" },
{ "from": "n_quote", "to": "n_design" },
{ "from": "n_design", "to": "n_route" },
{ "from": "n_design", "to": "n_product" },
{ "from": "n_design", "to": "n_bom" },
{ "from": "n_shop", "to": "n_plan" },
{ "from": "n_plan", "to": "n_wo" },
{ "from": "n_wo", "to": "n_pool" },
{ "from": "n_wo", "to": "n_issue" },
{ "from": "n_issue", "to": "n_qc" },
{ "from": "n_qc", "to": "n_receipt" },
{ "from": "n_wo", "to": "n_wh" },
{ "from": "n_issue", "to": "n_wh" },
{ "from": "n_receipt", "to": "n_wh" },
{ "from": "n_wh", "to": "n_out_oth" },
{ "from": "n_wh", "to": "n_transfer" },
{ "from": "n_wh", "to": "n_count" },
{ "from": "n_wh", "to": "n_in_oth" },
{ "from": "n_vendor", "to": "n_preq" },
{ "from": "n_preq", "to": "n_po" },
{ "from": "n_po", "to": "n_grn" },
{ "from": "n_grn", "to": "n_ap" },
{ "from": "n_grn", "to": "n_wh" },
{ "from": "n_ap", "to": "n_recon2" },
{ "from": "n_ap", "to": "n_ininv" },
{ "from": "n_recon2", "to": "n_getinv" },
{ "from": "n_ininv", "to": "n_getinv" },
{ "from": "n_ininv", "to": "n_ver2" },
{ "from": "n_getinv", "to": "n_ver2" },
{ "from": "n_fin", "to": "n_cash" },
{ "from": "n_cash", "to": "n_ver1" },
{ "from": "n_fin", "to": "n_pay" },
{ "from": "n_pay", "to": "n_ver2" }
]
}
+814
View File
@@ -0,0 +1,814 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>业务流程图</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; background: #EFF7FD; }
/* 顶部工具栏 */
#toolbar {
display: flex; align-items: center; gap: 10px; padding: 8px 14px;
background: linear-gradient(90deg, #1DA48E, #31C3AA); color: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,.12); z-index: 10; flex: 0 0 auto;
}
#toolbar .title { font-size: 16px; font-weight: 600; letter-spacing: 1px; }
#toolbar .spacer { flex: 1; }
#toolbar input.search {
width: 180px; padding: 5px 10px; border: none; border-radius: 14px;
outline: none; font-size: 13px; color: #0F9E87; background: rgba(255,255,255,.9);
}
#toolbar button {
border: none; background: rgba(255,255,255,.22); color: #fff; cursor: pointer;
border-radius: 6px; padding: 5px 10px; font-size: 13px; transition: background .15s;
}
#toolbar button:hover { background: rgba(255,255,255,.38); }
#modeTag {
font-size: 12px; background: rgba(255,255,255,.22); padding: 3px 9px;
border-radius: 12px;
}
#toolbar select.ds {
border: none; border-radius: 6px; padding: 5px 8px; font-size: 13px;
color: #0F9E87; background: rgba(255,255,255,.94); outline: none;
max-width: 240px; cursor: pointer;
}
/* 画布 */
#stage { position: relative; flex: 1; overflow: hidden; cursor: grab; }
#stage.panning { cursor: grabbing; }
#svg { width: 100%; height: 100%; display: block; user-select: none; }
/* 泳道 */
.lane-rect { fill: #fff; stroke: #CFE6F5; stroke-width: 1; }
.lane-head { fill: url(#laneHeadGrad); }
.lane-head-text { fill: #0F9E87; font-size: 14px; font-weight: 600; text-anchor: middle; }
.band-rect { fill: #F0FBF8; stroke: #8ADACB; stroke-width: 1; }
.band-text { fill: #0F9E87; font-size: 13px; font-weight: 600; text-anchor: middle; }
/* 节点 */
.bpd-node { cursor: move; }
.bpd-node .node-box { transition: filter .12s; }
.bpd-node:hover .node-box { filter: brightness(1.04); }
.bpd-node.selected .node-box { stroke-width: 2.4 !important; }
.bpd-node.hl .node-box { stroke: #FFB020 !important; stroke-width: 2.4 !important; }
.node-label { font-size: 12px; text-anchor: middle; pointer-events: none; }
.node-sub { font-size: 10px; text-anchor: middle; pointer-events: none; }
.node-port {
fill: #fff; stroke: #31C3AA; stroke-width: 1.6; cursor: crosshair; opacity: 0;
transition: opacity .12s;
}
.bpd-node:hover .node-port, .node-port.show { opacity: 1; }
/* 连线 */
.edge-path { fill: none; stroke: #4FC0A8; stroke-width: 1.6; pointer-events: none; }
.edge-path.dashed { stroke-dasharray: 5 4; }
.edge-hit { fill: none; stroke: transparent; stroke-width: 10; cursor: pointer; }
.edge-label { font-size: 11px; fill: #5B7B94; text-anchor: middle; pointer-events: none; }
/* 橡皮筋 */
.rubber { fill: none; stroke: #31C3AA; stroke-width: 2; stroke-dasharray: 6 4; }
/* 详情面板 */
#detail {
position: absolute; right: 12px; top: 12px; width: 230px; background: #fff;
border: 1px solid #CFE6F5; border-radius: 10px; padding: 12px; display: none;
box-shadow: 0 6px 18px rgba(20,80,120,.15); z-index: 9; font-size: 13px; color: #3a5a70;
}
#detail h4 { margin: 0 0 8px; font-size: 14px; color: #0F9E87; }
#detail input {
width: 100%; padding: 6px 8px; border: 1px solid #CFE6F5; border-radius: 6px;
outline: none; font-size: 13px; margin-bottom: 8px;
}
#detail .row { margin-bottom: 6px; color: #93AFC3; }
#detail button {
width: 100%; border: none; background: #1DA48E; color: #fff; padding: 6px;
border-radius: 6px; cursor: pointer; font-size: 13px;
}
#detail button:hover { background: #178d7a; }
/* 新建节点输入框 */
#newNodeInput {
position: fixed; display: none; z-index: 20; width: 160px; padding: 6px 8px;
border: 2px solid #31C3AA; border-radius: 6px; outline: none; font-size: 13px;
box-shadow: 0 4px 12px rgba(0,0,0,.18);
}
/* toast */
#toast {
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
background: rgba(30,40,50,.9); color: #fff; padding: 9px 16px; border-radius: 20px;
font-size: 13px; opacity: 0; transition: opacity .25s; z-index: 30; pointer-events: none;
}
#toast.show { opacity: 1; }
</style>
</head>
<body>
<div id="app">
<div id="toolbar">
<span class="title" id="diagramTitle">业务流程图</span>
<span id="modeTag">加载中…</span>
<span class="spacer"></span>
<select id="diagramSelect" class="ds" title="切换流程图" style="display:none"></select>
<input class="search" id="searchInput" placeholder="搜索节点…" autocomplete="off">
<button id="btnZoomOut" title="缩小">-</button>
<button id="btnZoomIn" title="放大">+</button>
<button id="btnFit" title="适应窗口">适应</button>
</div>
<div id="stage">
<svg id="svg">
<defs>
<linearGradient id="laneHeadGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#31C3AA"/>
<stop offset="1" stop-color="#1DA48E"/>
</linearGradient>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="7.5" refY="3"
orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L8,3 L0,6 Z" id="arrowPath" fill="#4FC0A8"/>
</marker>
</defs>
<g id="viewport">
<g id="layerLanes"></g>
<g id="layerEdges"></g>
<g id="layerNodes"></g>
<g id="layerRubber"></g>
</g>
</svg>
<div id="detail">
<h4>节点详情</h4>
<input id="detailName" placeholder="节点名称">
<div class="row" id="detailMeta"></div>
<button id="detailRename">保存名称</button>
</div>
<input id="newNodeInput" placeholder="输入流程名称,回车确认">
</div>
<div id="toast"></div>
</div>
<!-- 离线兜底数据:REST 不可用时使用(预览模式,只读) -->
<script id="fallback" type="application/json">
{
"key": "erp_mto",
"name": "ERP 业务流程(离散制造-MTO)",
"title": "ERP(离散制造-MTO)",
"canvas": { "width": 1560, "height": 980 },
"theme": {
"pageBg": "#EFF7FD", "canvasBg": "#FFFFFF", "canvasBorder": "#CFE6F5",
"bannerFrom": "#1DA48E", "bannerTo": "#31C3AA", "laneBorder": "#8ADACB",
"laneHeadBorder": "#6ACDBB", "laneHeadText": "#0F9E87", "bandBg": "#F0FBF8",
"bandText": "#0F9E87", "nodeFill": "#E8F2FB", "nodeBorder": "#A9CFE9",
"nodeText": "#5B7B94", "nodeSubText": "#93AFC3", "startFill": "#EAF4FD",
"primaryFill": "#C6E1F8", "primaryBorder": "#93C6EA", "primaryText": "#2F6D92",
"ghostFill": "#F5FAFE", "ghostBorder": "#D5E8F5", "ghostText": "#A2BCCE",
"gatewayFill": "#E5F6F1", "gatewayBorder": "#98D3C2", "gatewayText": "#3E8F81",
"flow": "#4FC0A8"
},
"lanes": [
{ "name": "销售管理", "kind": "column", "x": 24, "y": 86, "w": 326, "h": 804, "label_pos": "top-center" },
{ "name": "技术管理", "kind": "column", "x": 350, "y": 86, "w": 223, "h": 804, "label_pos": "top-center" },
{ "name": "生产管理", "kind": "column", "x": 573, "y": 86, "w": 725, "h": 804, "label_pos": "top-center" },
{ "name": "采购管理", "kind": "column", "x": 1298, "y": 86, "w": 238, "h": 804, "label_pos": "top-center" },
{ "name": "仓库管理", "kind": "band", "x": 573, "y": 470, "w": 725, "h": 140, "label_pos": "bottom-center" },
{ "name": "财务管理", "kind": "band", "x": 24, "y": 906, "w": 1512, "h": 62, "label_pos": "bottom-center" }
],
"nodes": [
{ "id": "n_customer", "lane": "销售管理", "label": "客户", "x": 235, "y": 130, "w": 96, "h": 34, "variant": "start" },
{ "id": "n_quote", "lane": "销售管理", "label": "报价单", "x": 235, "y": 196, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_so", "lane": "销售管理", "label": "销售订单", "x": 235, "y": 262, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_deliver", "lane": "销售管理", "label": "销售出库/退货", "sub": "批次", "x": 235, "y": 372, "w": 116, "h": 34, "variant": "doc" },
{ "id": "n_ar", "lane": "销售管理", "label": "应收账款明细", "x": 235, "y": 690, "w": 120, "h": 34, "variant": "doc" },
{ "id": "n_bill", "lane": "销售管理", "label": "开票", "x": 124, "y": 772, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_recon1", "lane": "销售管理", "label": "对账", "x": 235, "y": 772, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_bad", "lane": "销售管理", "label": "坏账发票", "x": 235, "y": 840, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_ver1", "lane": "财务管理", "label": "收款单核销", "x": 235, "y": 937, "w": 110, "h": 42, "variant": "gateway", "shape": "diamond" },
{ "id": "n_design", "lane": "技术管理", "label": "方案设计", "x": 388, "y": 229, "w": 104, "h": 34, "variant": "doc" },
{ "id": "n_route", "lane": "技术管理", "label": "生产工序", "x": 508, "y": 160, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_product", "lane": "技术管理", "label": "产品信息", "x": 508, "y": 229, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_bom", "lane": "技术管理", "label": "产品BOM", "x": 508, "y": 298, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_shop", "lane": "生产管理", "label": "车间", "x": 660, "y": 130, "w": 96, "h": 34, "variant": "start" },
{ "id": "n_plan", "lane": "生产管理", "label": "生产计划", "x": 639, "y": 328, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_pool", "lane": "生产管理", "label": "生产任务池", "x": 774, "y": 238, "w": 100, "h": 30, "variant": "ghost" },
{ "id": "n_wo", "lane": "生产管理", "label": "生产工单", "sub": "批次", "x": 774, "y": 328, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_issue", "lane": "生产管理", "label": "生产领/退料", "x": 919, "y": 328, "w": 120, "h": 34, "variant": "doc" },
{ "id": "n_qc", "lane": "生产管理", "label": "生产报工/质检", "x": 1080, "y": 328, "w": 132, "h": 34, "variant": "doc" },
{ "id": "n_receipt", "lane": "生产管理", "label": "生产入库", "x": 1231, "y": 328, "w": 100, "h": 34, "variant": "doc" },
{ "id": "n_wh", "lane": "仓库管理", "label": "仓库", "x": 838, "y": 400, "w": 100, "h": 34, "variant": "primary" },
{ "id": "n_out_oth", "lane": "仓库管理", "label": "其他出库单", "x": 660, "y": 555, "w": 110, "h": 34, "variant": "doc" },
{ "id": "n_transfer", "lane": "仓库管理", "label": "库存调拨", "x": 838, "y": 555, "w": 110, "h": 34, "variant": "doc" },
{ "id": "n_count", "lane": "仓库管理", "label": "库存盘点", "x": 1080, "y": 555, "w": 110, "h": 34, "variant": "doc" },
{ "id": "n_in_oth", "lane": "仓库管理", "label": "其他入库单", "x": 1226, "y": 555, "w": 110, "h": 34, "variant": "doc" },
{ "id": "n_vendor", "lane": "采购管理", "label": "供应商", "x": 1360, "y": 130, "w": 96, "h": 34, "variant": "start" },
{ "id": "n_preq", "lane": "采购管理", "label": "采购申请", "x": 1360, "y": 196, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_po", "lane": "采购管理", "label": "采购订单", "x": 1360, "y": 262, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_grn", "lane": "采购管理", "label": "采购入库/退货", "sub": "批次", "x": 1360, "y": 372, "w": 116, "h": 34, "variant": "doc" },
{ "id": "n_ap", "lane": "采购管理", "label": "应付账款明细", "x": 1360, "y": 690, "w": 120, "h": 34, "variant": "doc" },
{ "id": "n_recon2", "lane": "采购管理", "label": "对账", "x": 1350, "y": 772, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_getinv", "lane": "采购管理", "label": "收票", "x": 1478, "y": 772, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_ininv", "lane": "采购管理", "label": "进项发票", "x": 1350, "y": 840, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_ver2", "lane": "财务管理", "label": "付款单核销", "x": 1430, "y": 937, "w": 110, "h": 42, "variant": "gateway", "shape": "diamond" },
{ "id": "n_cash", "lane": "财务管理", "label": "收款单", "x": 520, "y": 937, "w": 96, "h": 34, "variant": "doc" },
{ "id": "n_fin", "lane": "财务管理", "label": "财务", "x": 790, "y": 937, "w": 120, "h": 34, "variant": "primary" },
{ "id": "n_pay", "lane": "财务管理", "label": "付款单", "x": 1050, "y": 937, "w": 96, "h": 34, "variant": "doc" }
],
"edges": [
{ "from": "n_customer", "to": "n_quote" }, { "from": "n_quote", "to": "n_so" },
{ "from": "n_so", "to": "n_deliver" }, { "from": "n_deliver", "to": "n_ar" },
{ "from": "n_ar", "to": "n_bill" }, { "from": "n_ar", "to": "n_recon1" },
{ "from": "n_bill", "to": "n_bad" }, { "from": "n_recon1", "to": "n_bad" },
{ "from": "n_bad", "to": "n_ver1" }, { "from": "n_deliver", "to": "n_wh" },
{ "from": "n_so", "to": "n_plan", "style": "dashed" },
{ "from": "n_quote", "to": "n_design" }, { "from": "n_design", "to": "n_route" },
{ "from": "n_design", "to": "n_product" }, { "from": "n_design", "to": "n_bom" },
{ "from": "n_shop", "to": "n_plan" }, { "from": "n_plan", "to": "n_wo" },
{ "from": "n_wo", "to": "n_pool" }, { "from": "n_wo", "to": "n_issue" },
{ "from": "n_issue", "to": "n_qc" }, { "from": "n_qc", "to": "n_receipt" },
{ "from": "n_wo", "to": "n_wh" }, { "from": "n_issue", "to": "n_wh" },
{ "from": "n_receipt", "to": "n_wh" }, { "from": "n_wh", "to": "n_out_oth" },
{ "from": "n_wh", "to": "n_transfer" }, { "from": "n_wh", "to": "n_count" },
{ "from": "n_wh", "to": "n_in_oth" }, { "from": "n_vendor", "to": "n_preq" },
{ "from": "n_preq", "to": "n_po" }, { "from": "n_po", "to": "n_grn" },
{ "from": "n_grn", "to": "n_ap" }, { "from": "n_grn", "to": "n_wh" },
{ "from": "n_ap", "to": "n_recon2" }, { "from": "n_ap", "to": "n_ininv" },
{ "from": "n_recon2", "to": "n_getinv" }, { "from": "n_ininv", "to": "n_getinv" },
{ "from": "n_ininv", "to": "n_ver2" }, { "from": "n_getinv", "to": "n_ver2" },
{ "from": "n_fin", "to": "n_cash" }, { "from": "n_cash", "to": "n_ver1" },
{ "from": "n_fin", "to": "n_pay" }, { "from": "n_pay", "to": "n_ver2" }
]
}
</script>
<script>
(function () {
"use strict";
const SVG_NS = "http://www.w3.org/2000/svg";
const $ = (id) => document.getElementById(id);
const state = {
mode: "loading", // loading | live | preview
diagram: null, // {id, name, canvas:{width,height}, theme}
theme: {},
lanes: [],
nodeMap: {}, // id -> node
edgeMap: {}, // id -> edge
scale: 1, tx: 0, ty: 0,
selected: null,
drag: null, // 当前拖拽上下文
rubber: null, // 拉线上下文
};
const DEFAULT_THEME = {
pageBg: "#EFF7FD", canvasBg: "#FFFFFF", canvasBorder: "#CFE6F5",
bannerFrom: "#1DA48E", bannerTo: "#31C3AA", laneBorder: "#8ADACB",
laneHeadBorder: "#6ACDBB", laneHeadText: "#0F9E87", bandBg: "#F0FBF8",
bandText: "#0F9E87", nodeFill: "#E8F2FB", nodeBorder: "#A9CFE9",
nodeText: "#5B7B94", nodeSubText: "#93AFC3", startFill: "#EAF4FD",
primaryFill: "#C6E1F8", primaryBorder: "#93C6EA", primaryText: "#2F6D92",
ghostFill: "#F5FAFE", ghostBorder: "#D5E8F5", ghostText: "#A2BCCE",
gatewayFill: "#E5F6F1", gatewayBorder: "#98D3C2", gatewayText: "#3E8F81",
flow: "#4FC0A8",
};
function toast(msg) {
const t = $("toast");
t.textContent = msg;
t.classList.add("show");
clearTimeout(toast._t);
toast._t = setTimeout(() => t.classList.remove("show"), 2200);
}
// ---------------- 数据加载 ----------------
function useFallback() {
let fb;
try { fb = JSON.parse($("fallback").textContent); } catch (e) { fb = null; }
if (fb) {
applyDiagram(fb);
state.mode = "preview";
$("modeTag").textContent = "预览模式 · 只读";
$("diagramSelect").style.display = "none";
} else {
$("modeTag").textContent = "无数据";
}
}
function idFromUrl() {
const v = new URLSearchParams(location.search).get("id");
const n = v ? parseInt(v, 10) : NaN;
return isNaN(n) ? null : n;
}
async function fetchDiagramList() {
try {
const r = await fetch("/yuthon_bpd/api/diagram/list", { credentials: "same-origin" });
if (!r.ok) return [];
const j = await r.json();
return (j && j.diagrams) || [];
} catch (e) { return []; }
}
function fillSelect(list, selectedId) {
const sel = $("diagramSelect");
if (!list.length) { sel.style.display = "none"; return; }
sel.innerHTML = list.map((d) =>
`<option value="${d.id}"${String(d.id) === String(selectedId) ? " selected" : ""}>${esc(d.name)}</option>`
).join("");
sel.style.display = "";
}
async function loadDiagram(id) {
const url = id ? `/yuthon_bpd/api/diagram/data?id=${id}`
: "/yuthon_bpd/api/diagram/data";
try {
const r = await fetch(url, { credentials: "same-origin" });
if (r.ok) {
const j = await r.json();
if (j && j.diagram) {
applyDiagram(j.diagram);
state.mode = "live";
$("modeTag").textContent = "编辑模式";
return true;
}
}
} catch (e) { /* 忽略,走兜底 */ }
return false;
}
async function loadData() {
// file:// 或 about: 协议下无法访问 REST,直接走内联兜底(离线预览 / 截图)
if (location.protocol === "file:" || location.protocol === "about:") {
useFallback();
return;
}
const list = await fetchDiagramList();
const want = idFromUrl() || (list.length ? list[0].id : null);
fillSelect(list, want);
const ok = await loadDiagram(want);
if (!ok) useFallback();
}
function applyDiagram(d) {
state.diagram = {
id: d.id || null,
name: d.name || d.title || "业务流程图",
canvas: d.canvas || { width: 1560, height: 980 },
theme: Object.assign({}, DEFAULT_THEME, d.theme || {}),
};
state.theme = state.diagram.theme;
state.lanes = d.lanes || [];
state.nodeMap = {};
(d.nodes || []).forEach((n) => { state.nodeMap[n.id] = n; });
state.edgeMap = {};
(d.edges || []).forEach((e) => { state.edgeMap[e.id] = e; });
$("diagramTitle").textContent = state.diagram.name;
document.body.style.background = state.theme.pageBg;
$("arrowPath").setAttribute("fill", state.theme.flow);
renderAll();
fit();
}
// ---------------- 坐标换算 ----------------
function screenToCanvas(clientX, clientY) {
const r = $("svg").getBoundingClientRect();
return {
x: (clientX - r.left - state.tx) / state.scale,
y: (clientY - r.top - state.ty) / state.scale,
};
}
function applyTransform() {
$("viewport").setAttribute(
"transform", `translate(${state.tx},${state.ty}) scale(${state.scale})`);
}
// ---------------- 渲染 ----------------
function nodeColors(n) {
const t = state.theme;
if (n.variant === "start") return { fill: t.startFill, stroke: t.nodeBorder, text: t.nodeText };
if (n.variant === "primary") return { fill: t.primaryFill, stroke: t.primaryBorder, text: t.primaryText };
if (n.variant === "ghost") return { fill: t.ghostFill, stroke: t.ghostBorder, text: t.ghostText };
if (n.variant === "gateway") return { fill: t.gatewayFill, stroke: t.gatewayBorder, text: t.gatewayText };
return { fill: t.nodeFill, stroke: t.nodeBorder, text: t.nodeText };
}
function renderAll() {
renderLanes();
renderEdges();
renderNodes();
}
function renderLanes() {
const g = $("layerLanes");
let s = "";
state.lanes.forEach((l) => {
if (l.kind === "column") {
s += `<rect class="lane-rect" x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="4"/>`;
s += `<rect class="lane-head" x="${l.x}" y="${l.y}" width="${l.w}" height="${l.head_h || 34}" rx="4"/>`;
s += `<text class="lane-head-text" x="${l.x + l.w / 2}" y="${l.y + (l.head_h || 34) / 2 + 5}">${esc(l.name)}</text>`;
} else { // band
s += `<rect class="band-rect" x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="4"/>`;
const ty = l.label_pos === "top-center" ? l.y + 18 : l.y + l.h - 12;
s += `<text class="band-text" x="${l.x + l.w / 2}" y="${ty}">${esc(l.name)}</text>`;
}
});
g.innerHTML = s;
}
function edgePathPoints(from, to) {
// 节点中心
const fc = { x: from.x, y: from.y }, tc = { x: to.x, y: to.y };
const dx = tc.x - fc.x, dy = tc.y - fc.y;
let out, inn;
if (Math.abs(dx) >= Math.abs(dy)) {
out = { x: dx >= 0 ? fc.x + from.w / 2 : fc.x - from.w / 2, y: fc.y };
inn = { x: dx >= 0 ? tc.x - to.w / 2 : tc.x + to.w / 2, y: tc.y };
} else {
out = { x: fc.x, y: dy >= 0 ? fc.y + from.h / 2 : fc.y - from.h / 2 };
inn = { x: tc.x, y: dy >= 0 ? tc.y - to.h / 2 : tc.y + to.h / 2 };
}
return { out, inn };
}
function orthoPath(out, inn) {
const r = 8;
const horiz = Math.abs(inn.x - out.x) >= Math.abs(inn.y - out.y);
if (horiz) {
const mx = (out.x + inn.x) / 2;
const my = inn.y;
let d = `M ${out.x} ${out.y} L ${mx - r} ${out.y} `;
d += `Q ${mx} ${out.y} ${mx} ${out.y + (my >= out.y ? r : -r)} `;
d += `L ${mx} ${my - (my >= out.y ? r : -r)} `;
d += `Q ${mx} ${my} ${mx + (inn.x >= mx ? r : -r)} ${my} `;
d += `L ${inn.x} ${my}`;
return d;
} else {
const my = (out.y + inn.y) / 2;
const mx = inn.x;
let d = `M ${out.x} ${out.y} L ${out.x} ${my - r} `;
d += `Q ${out.x} ${my} ${out.x + (mx >= out.x ? r : -r)} ${my} `;
d += `L ${mx - (mx >= out.x ? r : -r)} ${my} `;
d += `Q ${mx} ${my} ${mx} ${my + (inn.y >= my ? r : -r)} `;
d += `L ${mx} ${inn.y}`;
return d;
}
}
function renderEdges() {
const g = $("layerEdges");
let s = "";
Object.values(state.edgeMap).forEach((e) => {
const from = state.nodeMap[e.from], to = state.nodeMap[e.to];
if (!from || !to) return;
const { out, inn } = edgePathPoints(from, to);
const d = orthoPath(out, inn);
s += `<path class="edge-hit" d="${d}" data-edge="${e.id}"/>`;
s += `<path class="edge-path ${e.style === "dashed" ? "dashed" : ""}" d="${d}" marker-end="url(#arrow)" data-edge="${e.id}"/>`;
if (e.label) {
const mx = (out.x + inn.x) / 2, my = (out.y + inn.y) / 2;
s += `<text class="edge-label" x="${mx}" y="${my - 4}">${esc(e.label)}</text>`;
}
});
g.innerHTML = s;
}
function renderNodes() {
const g = $("layerNodes");
let s = "";
Object.values(state.nodeMap).forEach((n) => {
const c = nodeColors(n);
const sel = state.selected === n.id ? " selected" : "";
const hw = n.w / 2, hh = n.h / 2;
let box;
if (n.shape === "diamond") {
const pts = `0,${-hh} ${hw},0 0,${hh} ${-hw},0`;
box = `<polygon class="node-box" points="${pts}" fill="${c.fill}" stroke="${c.stroke}" stroke-width="1.4"/>`;
} else if (n.shape === "pill") {
box = `<rect class="node-box" x="${-hw}" y="${-hh}" width="${n.w}" height="${n.h}" rx="${hh}" fill="${c.fill}" stroke="${c.stroke}" stroke-width="1.4"/>`;
} else {
box = `<rect class="node-box" x="${-hw}" y="${-hh}" width="${n.w}" height="${n.h}" rx="6" fill="${c.fill}" stroke="${c.stroke}" stroke-width="1.4"/>`;
}
const fs = n.shape === "diamond" ? 11 : 12;
const ly = n.sub ? -2 : 4;
let label = `<text class="node-label" x="0" y="${ly}" font-size="${fs}" fill="${c.text}">${esc(n.label)}</text>`;
let sub = "";
if (n.sub) sub = `<text class="node-sub" x="0" y="11" fill="${state.theme.nodeSubText}">${esc(n.sub)}</text>`;
const port = `<circle class="node-port" cx="${hw + 9}" cy="0" r="5" data-node="${n.id}"/>`;
s += `<g class="bpd-node${sel}" data-id="${n.id}" transform="translate(${n.x},${n.y})">${box}${label}${sub}${port}</g>`;
});
g.innerHTML = s;
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[m]));
}
// ---------------- 交互 ----------------
function onDown(e) {
if (e.button !== 0) return;
const portEl = e.target.closest(".node-port");
if (portEl) {
e.preventDefault();
startRubber(parseInt(portEl.getAttribute("data-node"), 10), e);
return;
}
const nodeEl = e.target.closest(".bpd-node");
if (nodeEl) {
e.preventDefault();
startNodeDrag(parseInt(nodeEl.getAttribute("data-id"), 10), e);
return;
}
// 空白:平移
startPan(e);
}
function startPan(e) {
state.drag = { type: "pan", sx: e.clientX, sy: e.clientY, tx0: state.tx, ty0: state.ty };
$("stage").classList.add("panning");
}
function startNodeDrag(id, e) {
const n = state.nodeMap[id];
const p = screenToCanvas(e.clientX, e.clientY);
state.drag = { type: "node", id, ox: p.x - n.x, oy: p.y - n.y, moved: false };
}
function startRubber(id, e) {
const n = state.nodeMap[id];
state.rubber = { from: id, sx: n.x + n.w / 2, sy: n.y, ex: n.x + n.w / 2, ey: n.y };
renderRubber();
}
function onMove(e) {
if (state.drag && state.drag.type === "pan") {
state.tx = state.drag.tx0 + (e.clientX - state.drag.sx);
state.ty = state.drag.ty0 + (e.clientY - state.drag.sy);
applyTransform();
return;
}
if (state.drag && state.drag.type === "node") {
const p = screenToCanvas(e.clientX, e.clientY);
const n = state.nodeMap[state.drag.id];
n.x = Math.round(p.x - state.drag.ox);
n.y = Math.round(p.y - state.drag.oy);
state.drag.moved = true;
// 直接改该节点 port 位置 + 重绘相连边
moveNodeEl(n);
renderEdges();
return;
}
if (state.rubber) {
const p = screenToCanvas(e.clientX, e.clientY);
state.rubber.ex = p.x; state.rubber.ey = p.y;
renderRubber();
return;
}
}
function moveNodeEl(n) {
const el = document.querySelector(`.bpd-node[data-id="${n.id}"]`);
if (el) el.setAttribute("transform", `translate(${n.x},${n.y})`);
}
function renderRubber() {
const g = $("layerRubber");
if (!state.rubber) { g.innerHTML = ""; return; }
const r = state.rubber;
g.innerHTML = `<path class="rubber" d="M ${r.sx} ${r.sy} L ${r.ex} ${r.ey}"/>`;
}
function onUp(e) {
if (state.drag && state.drag.type === "pan") {
state.drag = null;
$("stage").classList.remove("panning");
return;
}
if (state.drag && state.drag.type === "node") {
const id = state.drag.id;
const moved = state.drag.moved;
state.drag = null;
if (moved) {
saveMove(id);
} else {
selectNode(id);
}
return;
}
if (state.rubber) {
const from = state.rubber.from;
const endCanvas = screenToCanvas(e.clientX, e.clientY);
state.rubber = null;
$("layerRubber").innerHTML = "";
// 命中节点?
const el = document.elementFromPoint(e.clientX, e.clientY);
const nodeEl = el && el.closest ? el.closest(".bpd-node") : null;
if (nodeEl) {
const to = parseInt(nodeEl.getAttribute("data-id"), 10);
if (to !== from) { createEdge(from, to); }
} else {
// 空白:弹输入框建新节点
showNewNodeInput(e.clientX, e.clientY, from, endCanvas);
}
return;
}
}
function selectNode(id) {
state.selected = id;
document.querySelectorAll(".bpd-node").forEach((g) => {
g.classList.toggle("selected", parseInt(g.getAttribute("data-id"), 10) === id);
});
const n = state.nodeMap[id];
const panel = $("detail");
panel.style.display = "block";
$("detailName").value = n.label;
$("detailMeta").textContent = `坐标 (${n.x}, ${n.y}) 泳道:${n.lane || "—"} 样式:${n.variant}`;
panel._nodeId = id;
}
function hideDetail() { $("detail").style.display = "none"; state.selected = null;
document.querySelectorAll(".bpd-node.selected").forEach((g) => g.classList.remove("selected")); }
function showNewNodeInput(cx, cy, from, endCanvas) {
const inp = $("newNodeInput");
inp.style.display = "block";
inp.style.left = cx + 8 + "px";
inp.style.top = cy + 8 + "px";
inp.value = "";
inp._ctx = { from, endCanvas };
setTimeout(() => inp.focus(), 0);
}
// ---------------- 写操作 ----------------
async function api(method, url, body) {
if (state.mode === "preview") {
toast("预览模式:请登录管理员账号后编辑");
return null;
}
try {
const r = await fetch(url, {
method, headers: { "Content-Type": "application/json" },
credentials: "same-origin", body: body ? JSON.stringify(body) : undefined,
});
if (r.status === 403) { toast("无权限:需要系统管理员"); return null; }
if (!r.ok) { toast("操作失败(" + r.status + ")"); return null; }
return await r.json();
} catch (e) { toast("网络错误"); return null; }
}
function laneAt(cx, cy) {
let lid = false;
state.lanes.forEach((l) => {
if (l.kind === "column" && cx >= l.x && cx <= l.x + l.w && cy >= l.y && cy <= l.y + l.h) lid = l.id || lid;
});
return lid;
}
async function createNode(label, x, y, from) {
const res = await api("POST", `/yuthon_bpd/api/diagram/${state.diagram.id}/node`, {
label, x, y, lane_id: laneAt(x, y), variant: "doc", shape: "box", w: 96, h: 34,
});
if (!res || !res.node) return;
const n = res.node;
state.nodeMap[n.id] = n;
renderNodes();
if (from != null) await createEdge(from, n.id);
}
async function createEdge(from, to) {
const res = await api("POST", `/yuthon_bpd/api/diagram/${state.diagram.id}/edge`, { from, to });
if (!res || !res.edge) return;
state.edgeMap[res.edge.id] = res.edge;
renderEdges();
}
async function saveMove(id) {
const n = state.nodeMap[id];
await api("POST", `/yuthon_bpd/api/node/${id}/move`, { x: n.x, y: n.y });
}
async function saveRename(id, label) {
const res = await api("POST", `/yuthon_bpd/api/node/${id}/rename`, { label });
if (res && res.ok) { state.nodeMap[id].label = res.label; renderNodes(); }
}
async function deleteEdge(id) {
const res = await api("POST", `/yuthon_bpd/api/edge/${id}/delete`, {});
if (res && res.ok) { delete state.edgeMap[id]; renderEdges(); }
}
// ---------------- 缩放 / 适应 ----------------
function zoomAt(cx, cy, factor) {
const r = $("svg").getBoundingClientRect();
const sx = cx - r.left, sy = cy - r.top;
const ns = Math.min(2.5, Math.max(0.3, state.scale * factor));
state.tx = sx - (sx - state.tx) * (ns / state.scale);
state.ty = sy - (sy - state.ty) * (ns / state.scale);
state.scale = ns;
applyTransform();
}
function fit() {
const ns = Object.values(state.nodeMap);
if (!ns.length) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
ns.forEach((n) => {
minX = Math.min(minX, n.x - n.w / 2); maxX = Math.max(maxX, n.x + n.w / 2);
minY = Math.min(minY, n.y - n.h / 2); maxY = Math.max(maxY, n.y + n.h / 2);
});
const pad = 40;
const cw = maxX - minX + pad * 2, ch = maxY - minY + pad * 2;
const r = $("svg").getBoundingClientRect();
const s = Math.min(r.width / cw, r.height / ch, 1.4);
state.scale = Math.max(0.3, s);
state.tx = (r.width - (maxX + minX) * state.scale) / 2;
state.ty = (r.height - (maxY + minY) * state.scale) / 2;
applyTransform();
}
function searchNode(kw) {
kw = (kw || "").trim();
document.querySelectorAll(".bpd-node.hl").forEach((g) => g.classList.remove("hl"));
if (!kw) return;
let first = null;
Object.values(state.nodeMap).forEach((n) => {
const hit = n.label.indexOf(kw) >= 0 || (n.sub && n.sub.indexOf(kw) >= 0);
const el = document.querySelector(`.bpd-node[data-id="${n.id}"]`);
if (hit && el) { el.classList.add("hl"); if (!first) first = n; }
});
if (first) {
const r = $("svg").getBoundingClientRect();
state.tx = r.width / 2 - first.x * state.scale;
state.ty = r.height / 2 - first.y * state.scale;
applyTransform();
}
}
// ---------------- 事件绑定 ----------------
function bind() {
const svg = $("svg");
svg.addEventListener("mousedown", onDown);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
svg.addEventListener("wheel", (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.12 : 1 / 1.12);
}, { passive: false });
$("btnZoomIn").addEventListener("click", () => {
const r = $("svg").getBoundingClientRect(); zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1.15);
});
$("btnZoomOut").addEventListener("click", () => {
const r = $("svg").getBoundingClientRect(); zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1 / 1.15);
});
$("btnFit").addEventListener("click", fit);
$("searchInput").addEventListener("input", (e) => searchNode(e.target.value));
$("diagramSelect").addEventListener("change", (e) => {
const id = parseInt(e.target.value, 10);
if (!id) return;
hideDetail();
history.replaceState(null, "", "?id=" + id);
loadDiagram(id);
});
$("detailRename").addEventListener("click", () => {
const id = $("detail")._nodeId;
if (id != null) saveRename(id, $("detailName").value.trim());
});
$("detailName").addEventListener("keydown", (e) => {
if (e.key === "Enter") $("detailRename").click();
});
const inp = $("newNodeInput");
inp.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const label = inp.value.trim();
const ctx = inp._ctx;
inp.style.display = "none";
if (label && ctx) createNode(label, Math.round(ctx.endCanvas.x), Math.round(ctx.endCanvas.y), ctx.from);
} else if (e.key === "Escape") {
inp.style.display = "none";
}
});
// 点击连线删除(仅提示,实际删除需二次确认)
$("layerEdges").addEventListener("click", (e) => {
const hit = e.target.closest(".edge-hit");
if (hit) {
const id = parseInt(hit.getAttribute("data-edge"), 10);
if (confirm("删除这条关联流程?")) deleteEdge(id);
}
});
// 点击空白取消选中
$("stage").addEventListener("mousedown", (e) => {
if (!e.target.closest(".bpd-node") && !e.target.closest("#detail")) hideDetail();
});
window.addEventListener("resize", () => { /* 保持当前变换,无需重算 */ });
}
// ---------------- 启动 ----------------
bind();
loadData();
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+85
View File
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ============ 流程图表单 / 列表 ============ -->
<record id="yuthon_bpd_diagram_view_form" model="ir.ui.view">
<field name="name">yuthon.bpd.diagram.form</field>
<field name="model">yuthon.bpd.diagram</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_open_visual" type="object"
string="可视化编辑" class="oe_highlight"/>
</header>
<sheet>
<group>
<field name="name"/>
<field name="key"/>
<field name="canvas_w"/>
<field name="canvas_h"/>
</group>
<notebook>
<page string="泳道">
<field name="lane_ids">
<list editable="bottom">
<field name="name"/>
<field name="kind"/>
<field name="x"/>
<field name="y"/>
<field name="w"/>
<field name="h"/>
<field name="head_h"/>
<field name="label_pos"/>
</list>
</field>
</page>
<page string="节点">
<field name="node_ids">
<list editable="bottom">
<field name="label"/>
<field name="sub"/>
<field name="lane_id"/>
<field name="shape"/>
<field name="variant"/>
<field name="x"/>
<field name="y"/>
<field name="w"/>
<field name="h"/>
</list>
</field>
</page>
<page string="连线">
<field name="edge_ids">
<list editable="bottom">
<field name="from_id"/>
<field name="to_id"/>
<field name="label"/>
<field name="style"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="yuthon_bpd_diagram_view_list" model="ir.ui.view">
<field name="name">yuthon.bpd.diagram.list</field>
<field name="model">yuthon.bpd.diagram</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="key"/>
<field name="canvas_w"/>
<field name="canvas_h"/>
</list>
</field>
</record>
<record id="yuthon_bpd_diagram_action" model="ir.actions.act_window">
<field name="name">流程图</field>
<field name="res_model">yuthon.bpd.diagram</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="yuthon_bpd_diagram_view_list"/>
</record>
</odoo>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- 顶层菜单 -->
<menuitem id="menu_yuthon_bpd_root"
name="业务流程图"
web_icon="yuthon_bpd,static/src/img/flow.png"
sequence="95"/>
<!-- 后台维护入口:仅系统管理员 -->
<menuitem id="menu_yuthon_bpd_admin" name="流程图管理"
parent="menu_yuthon_bpd_root"
action="yuthon_bpd_diagram_action"
groups="base.group_system"/>
<!-- 前端可视化入口:新标签打开 bpd_view.html,数据由 REST 接口提供 -->
<record id="action_yuthon_bpd_view" model="ir.actions.act_url">
<field name="name">流程图视图</field>
<field name="url">/yuthon_bpd/static/src/bpd_view.html</field>
<field name="target">new</field>
</record>
<menuitem id="menu_yuthon_bpd_view" name="业务流程度"
parent="menu_yuthon_bpd_root"
action="action_yuthon_bpd_view"
groups="base.group_user"/>
</odoo>