# -*- coding: utf-8 -*- """票 11 t11-4 — gen-rules-views.py:生成规则族 25 + 自定义视图 4 新 .bru(真实示例落地)。 数据源:.scratch/opportunity-e2e/specimens.json(32 标本 = 30 首采 + stage/scheme versions 补采 20260829)。 落点:D:\\code\\crm-api-docs\\商机模块\\商机管理\\商机规则\\{公海规则,阶段模板,方案卡模板}\\ D:\\code\\crm-api-docs\\商机模块\\销售机会\\自定义视图\\ 目录落点裁决(handoff §3.1):源码 @Tag 是「商机管理/商机规则」「销售机会/自定义视图」, 遵循现有集合树(顶层「商机模块/」)避免顶层重复文件夹。 纪律(handoff §3.5 / bruno-sync SKILL): - UTF-8 无 BOM、\\n 换行;幂等:目标文件已存在则跳过 - docs 块所有行 2 空格缩进;首行 `# 动作语义名`、第二行 `METHOD 路径`(对账钥匙勿动) - 节顺序:说明段 → ## 请求参数 → ## 请求示例 → ## 响应 data 结构 → ## 响应示例 - 有标本:`// 实测响应摘录(20260828 · 用户X · e2e 种子库;数组字段只示第 1 条)` 无标本(写操作):`// 示例数据,字段结构以上表为准,非真实返回` - Result 写操作:「响应 data 结构」段写 null(照 商机模块/状态流转/领取商机.bru 惯例) - folder.bru 无 type、无 tags;seq 动态取同父目录现有 folder.bru max+10 """ import io import json import os import re import sys sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') FORCE = '--force' in sys.argv HERE = os.path.dirname(os.path.abspath(__file__)) DOCS_ROOT = r'D:\code\crm-api-docs' MOD_ROOT = os.path.join(DOCS_ROOT, '商机模块') SP = json.load(open(os.path.join(HERE, 'specimens.json'), encoding='utf-8')) # ---------- 真实值(全部从标本现取,不硬编码) ---------- POOL_ID = SP['pool_detail']['params']['id'] # 748507245062389760 SCHEME_ID = SP['scheme_detail']['params']['id'] # 748508098259320832 STAGE_ID = SP['stage_detail']['params']['id'] # 747831302652166144 _seed_view = next(v for v in SP['view_list']['resp']['data'] if '测试视图' in v['name']) VIEW_ID = _seed_view['viewId'] _fdefs = SP['field_defs']['resp']['data'] FK0, FK1 = _fdefs[0]['fieldKey'], _fdefs[1]['fieldKey'] # customer_name / customer_type MECH = '// 示例数据,字段结构以上表为准,非真实返回' def measured(key): return '// 实测响应摘录(20260828 · 用户%s · e2e 种子库;数组字段只示第 1 条)' % SP[key]['who'] def j2(obj): return json.dumps(obj, ensure_ascii=False, indent=2) def env(data): return {'code': 0, 'success': True, 'message': 'success', 'data': data} def slim_page(key): d = SP[key]['resp']['data'] return dict(d, content=d['content'][:1]) def slim_list(key): return SP[key]['resp']['data'][:1] def table(rows, headers): out = ['| ' + ' | '.join(headers) + ' |', '| ' + ' | '.join(['---'] * len(headers)) + ' |'] out += ['| ' + ' | '.join(r) + ' |' for r in rows] return out def prefix_rows(rows, p): return [(p + n, t, d) for n, t, d in rows] # ---------- 响应 data 结构字段表(源码 DTO/实体逐字段) ---------- PAGE_SHELL_ROWS = [ ('total', 'number', '总条数'), ('size', 'number', '每页条数'), ('current', 'number', '当前页码'), ('pages', 'number', '总页数'), ('empty', 'boolean', '是否为空'), ] POOL_DTO_ROWS = [ ('id', 'string', '版本行 ID(Long 序列化为 string)'), ('createTime', 'string', '创建时间'), ('updateTime', 'string', '更新时间'), ('ruleCode', 'string', '规则编码(系统生成,同规则多版本共用)'), ('ruleName', 'string', '规则名称'), ('versionNo', 'string', '版本号(系统生成只读:V1.0/V1.1…)'), ('status', 'number', '状态:1草稿 2发布中 3已停用'), ('applyScope', 'number', '适用范围:1所有部门通用 2对应部门专用'), ('isDefault', 'number', '是否默认:1是 0否(仅通用可设)'), ('allowManualPool', 'number', '允许负责人手动转入公海:1是 0否'), ('autoRecycleEnabled', 'number', '启用自动回收:1是 0否'), ('recycleDays', 'number', '自动回收天数(启用回收时必填,≥1;未启用为 null)'), ('allowFreeClaim', 'number', '允许自由领取:1是 0否'), ('recycleRemindEnabled', 'number', '自动回收前提醒:1是 0否(仅回收启用时可开)'), ('remindDays', 'number', '提前提醒天数(启用提醒时必填,≥1 且 < 回收天数)'), ('ruleDesc', 'string', '规则说明'), ('deptIds', 'array', '适用部门 ID 列表(Long 序列化为 string;applyScope=1 时为空数组)'), ('deptNames', 'array', '适用部门名称列表(出参回显)'), ] STAGE_DTO_ROWS = [ ('id', 'string', '版本行 ID(Long 序列化为 string)'), ('createTime', 'string', '创建时间'), ('updateTime', 'string', '更新时间'), ('templateCode', 'string', '模板编码(系统生成,同模板多版本共用)'), ('templateName', 'string', '模板名称'), ('versionNo', 'string', '版本号(系统生成只读:V1.0/V1.1…)'), ('status', 'number', '状态:1草稿 2发布中 3已停用'), ('applyScope', 'number', '适用范围:1所有商机 2指定部门'), ('isDefault', 'number', '是否默认:1是 0否(仅所有商机范围可设)'), ('templateDesc', 'string', '模板说明'), ('deptIds', 'array', '适用部门 ID 列表(Long 序列化为 string;applyScope=1 时为空数组)'), ('deptNames', 'array', '适用部门名称列表(出参回显)'), ('nodes', 'array', '阶段节点有序列表(seqNo 按列表顺序重算;末位固定节点系统保证)'), ('nodes[].id', 'string', '节点 ID(出参回显)'), ('nodes[].seqNo', 'number', '阶段序号'), ('nodes[].stageDictCode', 'string', '阶段字典值(引用「商机阶段」字典分组项)'), ('nodes[].customNodeName', 'string', '自定义节点名称(空则带入字典名)'), ('nodes[].workGoal', 'string', '阶段工作目标'), ('nodes[].isFixed', 'number', '固定节点:1固定(已转项目)0普通(出参回显,入参忽略)'), ] SCHEME_DTO_ROWS = [ ('id', 'string', '版本行 ID(Long 序列化为 string)'), ('createTime', 'string', '创建时间'), ('updateTime', 'string', '更新时间'), ('templateCode', 'string', '模板编码(系统生成,同模板多版本共用)'), ('templateName', 'string', '模板名称'), ('versionNo', 'string', '版本号(系统生成只读:V1.0/V1.1…)'), ('status', 'number', '状态:1草稿 2发布中 3已停用'), ('applyScope', 'number', '适用范围:1所有部门通用 2对应部门专用'), ('isDefault', 'number', '是否默认:1是 0否(仅所有部门通用可设)'), ('templateDesc', 'string', '模板说明'), ('deptIds', 'array', '适用部门 ID 列表(Long 序列化为 string;applyScope=1 时为空数组)'), ('deptNames', 'array', '适用部门名称列表(出参回显)'), ('fields', 'array', '字段项有序列表(seqNo 按列表顺序重算)'), ('fields[].id', 'string', '字段项 ID(出参回显)'), ('fields[].seqNo', 'number', '序号'), ('fields[].fieldKey', 'string', '字段键(引用系统字段定义)'), ('fields[].fieldName', 'string', '展示字段名(系统字段快照回显;入参忽略)'), ('fields[].fieldType', 'string', '字段类型(系统字段快照回显;入参忽略)'), ('fields[].isVisible', 'number', '显示开关:1显示 0不显示(关闭则不出现在填写页且清必填)'), ('fields[].isRequired', 'number', '必填:1必填 0非必填(仅显示时可为 1)'), ] VER_BASE_ROWS = [ ('id', 'string', '版本行 ID(Long 序列化为 string)'), ('creatorId', 'string', '创建人 ID(实体直出内部字段;D-19 P2 已登记待收敛)'), ('createTime', 'string', '创建时间'), ('updaterId', 'string', '更新人 ID(实体直出内部字段)'), ('updateTime', 'string', '更新时间'), ('deleted', 'boolean', '逻辑删除标记(实体直出,正常恒为 false)'), ] POOL_VER_ROWS = VER_BASE_ROWS + [ ('ruleCode', 'string', '规则编码'), ('ruleName', 'string', '规则名称'), ('versionNo', 'string', '版本号'), ('status', 'number', '状态:1草稿 2发布中 3已停用'), ('applyScope', 'number', '适用范围:1所有部门通用 2对应部门专用'), ('isDefault', 'number', '是否默认:1是 0否'), ('allowManualPool', 'number', '允许负责人手动转入公海:1是 0否'), ('autoRecycleEnabled', 'number', '启用自动回收:1是 0否'), ('recycleDays', 'number', '自动回收天数'), ('allowFreeClaim', 'number', '允许自由领取:1是 0否'), ('recycleRemindEnabled', 'number', '自动回收前提醒:1是 0否'), ('remindDays', 'number', '提前提醒天数'), ('ruleDesc', 'string', '规则说明'), ] STAGE_VER_ROWS = VER_BASE_ROWS + [ ('templateCode', 'string', '模板编码'), ('templateName', 'string', '模板名称'), ('versionNo', 'string', '版本号'), ('status', 'number', '状态:1草稿 2发布中 3已停用'), ('applyScope', 'number', '适用范围:1所有商机 2指定部门'), ('isDefault', 'number', '是否默认:1是 0否'), ('templateDesc', 'string', '模板说明'), ] FIELD_DEF_ROWS = [ ('id', 'string', '字段定义 ID'), ('createTime', 'string', '创建时间'), ('updateTime', 'string', '更新时间'), ('fieldKey', 'string', '字段键(模板字段项引用它)'), ('fieldName', 'string', '字段名(展示名)'), ('fieldType', 'string', '类型:RELATED_OBJECT/SINGLE_SELECT/AUTOFILL/YES_NO/ATTACHMENT/AMOUNT/TEXT_SINGLE/TEXT_MULTI/DATE'), ('optionSource', 'string', '选项来源(单选下拉类:字典分组 code)'), ('refTarget', 'string', '关联对象指向(关联对象类:目标实体语义,如 customer/contact)'), ('autofillSource', 'string', '自动回填来源(自动回填类:来源字段语义)'), ('defaultValue', 'string', '默认值'), ('isDeletable', 'number', '是否可删除:1可删 0内置不可删'), ] VIEW_ROWS = [ ('viewId', 'string', '视图 ID(服务端生成;save 新建时返回,编辑/删除/设默认须回传)'), ('name', 'string', '检索名称(用户填)'), ('conditions', 'array', '检索条件列表(本期前端只提交 1 组)'), ('conditions[].field', 'string', '字段 key(业务方字段池内的稳定 code)'), ('conditions[].operator', 'string', '操作符(业务方自定义 code,如 eq 等于 / like 包含)'), ('conditions[].value', 'string', '条件值(为空/不为空操作符时可为 null)'), ('sortField', 'string', '排序字段 key(可空)'), ('sortDirection', 'string', '排序方向 asc/desc(可空)'), ('isDefault', 'boolean', '是否默认视图(单值互斥)'), ('seqNo', 'number', '视图在列表中的排序序号'), ] # ---------- 请求参数表 ---------- PAGE_IN_BASE = [ ('current', 'number', '否', '1', '当前页码,从 1 开始'), ('size', 'number', '否', '10', '每页条数,上限 500'), ('keyword', 'string', '否', '-', 'KW_NOTE'), ('orderBy', 'string', '否', '-', '排序字段(驼峰或下划线均可,如 createTime)'), ('asc', 'boolean', '否', 'false', '是否升序(默认 false,新数据在前)'), ] POOL_SAVE_IN = [ ('id', 'string', '否', '-', '编辑草稿时传版本行 ID;新建不传'), ('ruleName', 'string', '是', '-', '规则名称'), ('applyScope', 'number', '是', '-', '适用范围:1所有部门通用 2对应部门专用'), ('isDefault', 'number', '否', '0', '是否默认:1是 0否(仅通用可设)'), ('allowManualPool', 'number', '否', '1', '允许负责人手动转入公海:1是 0否'), ('autoRecycleEnabled', 'number', '否', '1', '启用自动回收:1是 0否;=0 时 recycleRemindEnabled 须显式传 0(省略按开启处理)'), ('recycleDays', 'number', '条件', '-', '自动回收天数(启用回收时必填,≥1)'), ('allowFreeClaim', 'number', '否', '1', '允许自由领取:1是 0否'), ('recycleRemindEnabled', 'number', '条件', '0', '自动回收前提醒:1是 0否(仅回收启用时可开)'), ('remindDays', 'number', '条件', '-', '提前提醒天数(启用提醒时必填,≥1 且 < 回收天数)'), ('ruleDesc', 'string', '否', '-', '规则说明'), ('deptIds', 'string', '条件', '-', '适用部门 ID 列表,逗号分隔(applyScope=2 时必填至少一个)'), ] STAGE_SAVE_IN = [ ('id', 'string', '否', '-', '编辑草稿时传版本行 ID;新建不传'), ('templateName', 'string', '是', '-', '模板名称'), ('applyScope', 'number', '是', '-', '适用范围:1所有商机 2指定部门'), ('isDefault', 'number', '否', '0', '是否默认:1是 0否(仅所有商机范围可设)'), ('templateDesc', 'string', '否', '-', '模板说明'), ('deptIds', 'string', '条件', '-', '适用部门 ID 列表,逗号分隔(applyScope=2 时必填至少一个)'), ('nodes[N].stageDictCode', 'string', '是', '-', '第 N 个节点的阶段字典值(N 从 0 递增的索引式表单绑定,引用「商机阶段」字典分组项)'), ('nodes[N].customNodeName', 'string', '否', '-', '自定义节点名称(空则带入字典名)'), ('nodes[N].workGoal', 'string', '否', '-', '阶段工作目标'), ] SCHEME_SAVE_IN = [ ('id', 'string', '否', '-', '编辑草稿时传版本行 ID;新建不传'), ('templateName', 'string', '是', '-', '模板名称'), ('applyScope', 'number', '是', '-', '适用范围:1所有部门通用 2对应部门专用'), ('isDefault', 'number', '否', '0', '是否默认:1是 0否(仅所有部门通用可设)'), ('templateDesc', 'string', '否', '-', '模板说明'), ('deptIds', 'string', '条件', '-', '适用部门 ID 列表,逗号分隔(applyScope=2 时必填至少一个)'), ('fields[N].fieldKey', 'string', '是', '-', '第 N 个字段项的字段键(索引式表单绑定,必须取「系统字段库列表」实测 fieldKey,非自由造)'), ('fields[N].isVisible', 'number', '否', '1', '显示开关:1显示 0不显示(关闭则不出现在填写页且清必填)'), ('fields[N].isRequired', 'number', '否', '0', '必填:1必填 0非必填(仅显示时可为 1)'), ] # ---------- 写操作实测构造(e2e-rules.py / seed-opportunity.py 实测通过口径) ---------- POOL_SAVE_PAIRS = [ ('ruleName', 'e2e-演示-公海规则'), ('applyScope', '1'), ('isDefault', '0'), ('allowManualPool', '1'), ('autoRecycleEnabled', '1'), ('recycleDays', '30'), ('allowFreeClaim', '1'), ('recycleRemindEnabled', '1'), ('remindDays', '7'), ('ruleDesc', '票11演示'), ] STAGE_SAVE_PAIRS = [ ('templateName', 'e2e-演示-阶段模板'), ('applyScope', '1'), ('isDefault', '0'), ('templateDesc', '票11演示'), ('nodes[0].stageDictCode', 'OPP_STAGE_01'), ('nodes[0].workGoal', '需求确认'), ('nodes[1].stageDictCode', 'OPP_STAGE_02'), ('nodes[1].customNodeName', '方案深化'), ] SCHEME_SAVE_PAIRS = [ ('templateName', 'e2e-演示-方案卡模板'), ('applyScope', '1'), ('isDefault', '0'), ('templateDesc', '票11演示'), ('fields[0].fieldKey', FK0), ('fields[0].isVisible', '1'), ('fields[0].isRequired', '1'), ('fields[1].fieldKey', FK1), ('fields[1].isVisible', '1'), ('fields[1].isRequired', '0'), ] VIEW_SAVE_OBJ = { 'name': 'e2e-B-测试视图-我的推进', 'conditions': [ {'field': 'opportunityName', 'operator': 'like', 'value': 'e2e-'}, {'field': 'oppStatus', 'operator': 'eq', 'value': '2'}, ], 'sortField': 'createTime', 'sortDirection': 'desc', 'isDefault': False, 'seqNo': 99, } # ---------- .bru 组装 ---------- CREATED, SKIPPED, REWRITTEN = [], [], [] # 仅限本脚本生成的 4 个新目录内允许 --force 重写(防误伤既有集合文件) OWN_DIRS = ('商机规则', '自定义视图') def write_file(path, content): rel = os.path.relpath(path, DOCS_ROOT) exists = os.path.exists(path) if exists and not (FORCE and any(seg in rel for seg in OWN_DIRS)): SKIPPED.append(rel) return os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'w', encoding='utf-8', newline='\n') as f: f.write(content) (CREATED if not exists else REWRITTEN).append(rel) def next_seq(parent): mx = 0 if os.path.isdir(parent): for d in sorted(os.listdir(parent)): fp = os.path.join(parent, d, 'folder.bru') if os.path.isfile(fp): m = re.search(r'seq:\s*(\d+)', open(fp, encoding='utf-8').read()) if m: mx = max(mx, int(m.group(1))) return mx + 10 def write_folder(parent, name): seq = next_seq(parent) write_file(os.path.join(parent, name, 'folder.bru'), 'meta {\n name: %s\n seq: %d\n}\n' % (name, seq)) def qblock(pairs): return '\n'.join(['params:query {'] + [' %s: %s' % kv for kv in pairs] + ['}']) def fblock(pairs): return '\n'.join(['body:form-urlencoded {'] + [' %s: %s' % kv for kv in pairs] + ['}']) def jblock(json_text): return '\n'.join(['body:json {'] + [' ' + l for l in json_text.splitlines()] + ['}']) def make_bru(name, seq, method, path, transport, param_text, docs_text): body = {'GET': 'none', 'FORM': 'formUrlEncoded', 'JSON': 'json'}[transport] verb = 'get' if method == 'GET' else 'post' L = ['meta {', ' name: ' + name, ' type: http', ' seq: ' + str(seq), ' tags: [', ' generated', ' ]', '}', '', verb + ' {', ' url: {{baseUrl}}' + path, ' body: ' + body, ' auth: inherit', '}', ''] if param_text: L += [param_text, ''] L += [docs_text] return '\n'.join(L) + '\n' def make_docs(title, key, intro, req_md, ex_md, struct_md, resp_comment, resp_json): struct_lines = struct_md if isinstance(struct_md, list) else [struct_md] L = ['# ' + title, '`' + key + '`'] L += list(intro) L += ['', '## 请求参数', ''] + list(req_md) L += ['', '## 请求示例', ''] + list(ex_md) L += ['', '## 响应 data 结构', ''] + struct_lines L += ['', '## 响应示例', '', resp_comment, '```json'] L += resp_json.splitlines() L += ['```'] return 'docs {\n' + '\n'.join((' ' + l) if l else '' for l in L) + '\n}' def ex_form(method, path, pairs): qs = '&'.join('%s=%s' % kv for kv in pairs) return ['请求头:Authorization: Bearer {{token}}', 'Content-Type: application/x-www-form-urlencoded', '', method + ' ' + path, qs] def ex_get(path, pairs): qs = '&'.join('%s=%s' % kv for kv in pairs) return ['请求头:Authorization: Bearer {{token}}', '', 'GET ' + path + '?' + qs] def ex_json(path, query_pairs, json_text): qs = ('?' + '&'.join('%s=%s' % kv for kv in query_pairs)) if query_pairs else '' L = ['请求头:Authorization: Bearer {{token}}', '', 'POST ' + path + qs, 'Content-Type: application/json', '', '```json'] L += json_text.splitlines() L += ['```'] return L def page_struct_rows(biz_rows, row_noun): return ([('content', 'array', 'PageResult.content ' + row_noun)] + prefix_rows(biz_rows, 'content[].') + PAGE_SHELL_ROWS) def struct_table(rows): return table(rows, ('字段', '类型', '说明')) # ---------- 规则三族生成 ---------- def build_family(cfg): sub, base, title = cfg['sub'], cfg['base'], cfg['title'] tpl_id = cfg['tpl_id'] parent = os.path.join(MOD_ROOT, '商机管理', '商机规则') write_folder(parent, sub) fdir = os.path.join(parent, sub) def emit(name, seq, method, path, transport, param_text, key_line, intro, req_md, ex_md, struct_md, resp_data, resp_key): comment = measured(resp_key) if resp_key else MECH docs = make_docs(name, key_line, intro, req_md, ex_md, struct_md, comment, j2(env(resp_data))) write_file(os.path.join(fdir, name + '.bru'), make_bru(name, seq, method, path, transport, param_text, docs)) code_col = cfg['code_col'] page_rows = [r if r[4] != 'KW_NOTE' else (r[0], r[1], r[2], r[3], cfg['keyword_note']) for r in PAGE_IN_BASE] page_rows += [ ('applyScope', 'number', '否', '-', '适用范围筛选:' + cfg['scope_desc']), ('status', 'number', '否', '-', '状态筛选:1草稿 2发布中 3已停用'), ] id_only = lambda desc: [('id', 'string', '是', '-', desc)] # 1 分页 emit(title + '分页', 1, 'POST', base + '/page', 'FORM', fblock([('current', '1'), ('size', '10')]), 'POST ' + base + '/page', ['%s配置列表,按版本维度展示(同 %s 草稿/发布中/停用各占一行)。' % (title, code_col), cfg['keyword_note'] + '。'], table(page_rows, ('参数', '类型', '必填', '默认', '说明')), ex_form('POST', base + '/page', [('current', '1'), ('size', '10')]), struct_table(page_struct_rows(cfg['biz_rows'], cfg['row_noun'])), slim_page(cfg['page_key']), cfg['page_key']) # 2 详情 emit(title + '详情', 2, 'GET', base + '/detail', 'GET', qblock([('id', tpl_id)]), 'GET ' + base + '/detail', ['%s详情(含适用部门%s)。' % (title, cfg['detail_extra'])], table(id_only('版本行 ID'), ('参数', '类型', '必填', '默认', '说明')), ex_get(base + '/detail', [('id', tpl_id)]), struct_table(cfg['biz_rows']), SP[cfg['detail_key']]['resp']['data'], cfg['detail_key']) seq = 3 if cfg.get('field_defs'): # 方案卡模板专属:系统字段库列表 emit('系统字段库列表', seq, 'GET', base + '/field-defs', 'GET', None, 'GET ' + base + '/field-defs', ['系统字段库列表:模板字段项 fieldKey 的合法取值源(只读,无参数)。'], ['无参数。'], ex_get(base + '/field-defs', []), struct_table(FIELD_DEF_ROWS), slim_list('field_defs'), 'field_defs') seq += 1 # 保存草稿 emit('保存%s草稿' % title, seq, 'POST', base + '/save-draft', 'FORM', fblock(cfg['save_pairs']), 'POST ' + base + '/save-draft', ['保存草稿:新建 / 原位编辑草稿 / 从发布中停用生成新草稿(三态复用)。'] + cfg['save_extra'], table(cfg['save_in_rows'], ('参数', '类型', '必填', '默认', '说明')), ex_form('POST', base + '/save-draft', cfg['save_pairs']), 'null', None, None) seq += 1 # 发布 pub_rows = list(cfg['save_in_rows']) pub_rows[0] = ('id', 'string', '是', '-', '须传 save-draft 产出的草稿版本行 ID') pub_pairs = [('id', '1')] + list(cfg['save_pairs']) emit('发布' + title, seq, 'POST', base + '/publish', 'FORM', fblock(pub_pairs), 'POST ' + base + '/publish', ['保存并发布:原发布中转停用、原默认取消默认、部门覆盖校验。', '⚠ 发布将顶替同编码发布中版本;id 须替换为 save-draft 产出的草稿 id,' '严禁对种子「%s」直接执行。' % cfg['seed']], table(pub_rows, ('参数', '类型', '必填', '默认', '说明')), ex_form('POST', base + '/publish', pub_pairs), 'null', None, None) seq += 1 # 复制 emit('复制' + title, seq, 'POST', base + '/copy', 'FORM', fblock([('id', tpl_id)]), 'POST ' + base + '/copy', ['复制:独立新%s(新编码 + V1.0 草稿%s)。' % (title, cfg['copy_extra']), '对发布中示例 id 照抄 = 新建草稿副本(无害),可用「删除%s」练手清掉。' % title], table(id_only('被复制版本行 ID'), ('参数', '类型', '必填', '默认', '说明')), ex_form('POST', base + '/copy', [('id', tpl_id)]), 'string', '1', None) seq += 1 # 停用 emit('停用' + title, seq, 'POST', base + '/disable', 'FORM', fblock([('id', '1')]), 'POST ' + base + '/disable', ['停用:仅发布中可停用。', '⚠ id 须替换为发布中版本行 id(勿误停种子「%s」)。' % cfg['seed']], table(id_only('发布中版本行 ID'), ('参数', '类型', '必填', '默认', '说明')), ex_form('POST', base + '/disable', [('id', '1')]), 'null', None, None) seq += 1 # 删除 emit('删除' + title, seq, 'POST', base + '/delete', 'FORM', fblock([('id', '1')]), 'POST ' + base + '/delete', [cfg['delete_note'], '⚠ id 须替换为草稿 id(可先用「复制%s」造一条草稿练手)。' % title], table(id_only('草稿版本行 ID'), ('参数', '类型', '必填', '默认', '说明')), ex_form('POST', base + '/delete', [('id', '1')]), 'null', None, None) seq += 1 # 版本列表 emit(title + '版本列表', seq, 'GET', base + '/versions', 'GET', qblock([('id', tpl_id)]), 'GET ' + base + '/versions', ['同族版本列表:按 %s 查所有版本。' % code_col, '实体直出(无 DTO 包装),不含%s子表。' % cfg['ver_missing']], table(id_only('任一版本行 ID(按其编码查全族)'), ('参数', '类型', '必填', '默认', '说明')), ex_get(base + '/versions', [('id', tpl_id)]), struct_table(cfg['ver_rows']), slim_list(cfg['versions_key']), cfg['versions_key']) FAMILIES = [ dict( sub='公海规则', title='公海规则', base='/api/rule/opp-pool-rule', code_col='ruleCode', row_noun='规则版本行列表', page_key='pool_page', detail_key='pool_detail', versions_key='pool_versions', tpl_id=POOL_ID, seed='e2e-seed公海规则(POOL_RULE_01)', scope_desc='1所有部门通用 2对应部门专用', keyword_note='模糊搜索(匹配规则名称/规则编码/版本号)', detail_extra='', biz_rows=POOL_DTO_ROWS, ver_rows=POOL_VER_ROWS, save_pairs=POOL_SAVE_PAIRS, save_in_rows=POOL_SAVE_IN, save_extra=['V-CONFIG 语义:同 ruleCode 至多一草稿一发布中;' 'autoRecycleEnabled=0 时 recycleRemindEnabled 须显式传 0(省略按开启处理)。'], copy_extra='', delete_note='删除:仅草稿可删。', ver_missing=' deptIds/deptNames ', ), dict( sub='阶段模板', title='阶段模板', base='/api/rule/opp-stage-template', code_col='templateCode', row_noun='模板版本行列表', page_key='stage_page', detail_key='stage_detail', versions_key='stage_versions', tpl_id=STAGE_ID, seed='全公司通用阶段模板(OPP_STAGE_TPL_01)', scope_desc='1所有商机 2指定部门', keyword_note='模糊搜索(匹配模板名称/模板编码/版本号)', detail_extra=' + 阶段节点有序列表', biz_rows=STAGE_DTO_ROWS, ver_rows=STAGE_VER_ROWS, save_pairs=STAGE_SAVE_PAIRS, save_in_rows=STAGE_SAVE_IN, save_extra=['节点为索引式表单绑定(nodes[0].xxx = 第 1 个节点…),节点全量替换;', '末位固定节点 OPP_STAGE_05(已转项目)系统保证,入参不可伪造(伪造被 64000 类校验拒绝)。'], copy_extra=',节点随复制', delete_note='删除:仅草稿可删(级联删节点与适用部门)。', ver_missing=' nodes/deptIds/deptNames ', ), dict( sub='方案卡模板', title='方案卡模板', base='/api/rule/opp-scheme-template', code_col='templateCode', row_noun='模板版本行列表', page_key='scheme_page', detail_key='scheme_detail', versions_key='scheme_versions', tpl_id=SCHEME_ID, seed='全公司通用方案卡模板(OPP_SCHEME_TPL_04)', scope_desc='1所有部门通用 2对应部门专用', keyword_note='模糊搜索(匹配模板名称/模板编码/版本号)', detail_extra=' + 字段项有序列表', biz_rows=SCHEME_DTO_ROWS, ver_rows=STAGE_VER_ROWS, save_pairs=SCHEME_SAVE_PAIRS, save_in_rows=SCHEME_SAVE_IN, save_extra=['字段项为索引式表单绑定(fields[0].xxx = 第 1 个字段项…),字段项全量替换,' 'fieldKey 必须取「系统字段库列表」实测值,非自由造;', 'fieldName/fieldType 入参忽略(取系统字段快照回显),isVisible=0 会清必填。'], copy_extra=',字段配置随复制', delete_note='删除:仅草稿可删(级联删字段项与适用部门)。', ver_missing=' fields/deptIds/deptNames ', field_defs=True, ), ] # ---------- 自定义视图生成 ---------- def build_views(): parent = os.path.join(MOD_ROOT, '销售机会') write_folder(parent, '自定义视图') fdir = os.path.join(parent, '自定义视图') base = '/api/preference/view' def emit(name, seq, method, path, transport, param_text, key_line, intro, req_md, ex_md, struct_md, resp_data, resp_key): comment = measured(resp_key) if resp_key else MECH docs = make_docs(name, key_line, intro, req_md, ex_md, struct_md, comment, j2(env(resp_data))) write_file(os.path.join(fdir, name + '.bru'), make_bru(name, seq, method, path, transport, param_text, docs)) param_table = lambda rows: table(rows, ('参数', '类型', '必填', '默认', '说明')) # 1 列表 emit('自定义视图列表', 1, 'GET', base + '/list', 'GET', qblock([('scopeKey', 'opportunity')]), 'GET ' + base + '/list', ['当前用户在某 scope 下的自建视图(内置视图不走本接口),按 seqNo 排序。', '出参不回显 scopeKey;商机域实测 scopeKey=opportunity。'], param_table([('scopeKey', 'string', '是', '-', '视图域标识;商机域实测传 opportunity')]), ex_get(base + '/list', [('scopeKey', 'opportunity')]), struct_table(VIEW_ROWS), slim_list('view_list'), 'view_list') # 2 保存(query scopeKey + body:json) vjson = j2(VIEW_SAVE_OBJ) emit('保存自定义视图', 2, 'POST', base + '/save', 'JSON', qblock([('scopeKey', 'opportunity')]) + '\n\n' + jblock(vjson), 'POST ' + base + '/save', ['保存自定义视图:viewId 空=新建,非空=覆盖编辑(须回传原 viewId);' 'isDefault 单值互斥(设默认自动取消原默认)。', 'scopeKey 走 query 参数,JSON body 才是视图本体;商机域实测 scopeKey=opportunity。'], param_table([ ('scopeKey', 'string', '是', '-', 'query 参数:视图域标识(opportunity)'), ('viewId', 'string', '否', '-', 'JSON body:空=新建;非空=覆盖编辑'), ('name', 'string', '是', '-', 'JSON body:检索名称(用户填)'), ('conditions', 'array', '否', '-', 'JSON body:检索条件列表(本期前端只提交 1 组)'), ('conditions[].field', 'string', '条件', '-', '字段 key(商机字段池稳定 code,如 opportunityName)'), ('conditions[].operator', 'string', '条件', '-', '操作符(如 eq 等于 / like 包含)'), ('conditions[].value', 'string', '条件', '-', '条件值'), ('sortField', 'string', '否', '-', 'JSON body:排序字段 key(可空)'), ('sortDirection', 'string', '否', '-', 'JSON body:排序方向 asc/desc(可空)'), ('isDefault', 'boolean', '否', 'false', 'JSON body:是否默认视图(单值互斥)'), ('seqNo', 'number', '否', '-', 'JSON body:列表排序序号'), ]), ex_json(base + '/save', [('scopeKey', 'opportunity')], vjson), struct_table([('viewId', 'string', '新建或覆盖后视图的 viewId(服务端生成)')]), VIEW_ID, None) # 3 删除 / 4 设为默认 del_pairs = [('scopeKey', 'opportunity'), ('viewId', '1')] for name, path, intro in [ ('删除自定义视图', base + '/delete', ['删除本人名下自定义视图。', '⚠ viewId 须替换为本人视图 id(「自定义视图列表」可查;勿对他人/种子视图执行)。']), ('设为默认视图', base + '/set-default', ['图钉:设为该 scope 的默认视图(单值互斥)。', '⚠ viewId 须替换为本人视图 id(「自定义视图列表」可查;勿对他人/种子视图执行)。']), ]: emit(name, 3 if path.endswith('delete') else 4, 'POST', path, 'FORM', fblock(del_pairs), 'POST ' + path, intro, param_table([ ('scopeKey', 'string', '是', '-', '视图域标识(opportunity)'), ('viewId', 'string', '是', '-', '视图 ID(须替换为本人视图 id)'), ]), ex_form('POST', path, del_pairs), 'null', None, None) # ---------- 主流程 ---------- if __name__ == '__main__': for cfg in FAMILIES: build_family(cfg) build_views() print('created:', len(CREATED), ' rewritten:', len(REWRITTEN), ' skipped:', len(SKIPPED)) for rel in CREATED: print(' +', rel) for rel in REWRITTEN: print(' ~', rel, '(--force 重写)') for rel in SKIPPED: print(' =', rel, '(已存在,跳过)') bom = [] for rel in CREATED + REWRITTEN: with open(os.path.join(DOCS_ROOT, rel), 'rb') as f: if f.read(3) == b'\xef\xbb\xbf': bom.append(rel) if bom: print('!! BOM 文件:', bom) sys.exit(1) total = sum(1 for dp, dn, fn in os.walk(MOD_ROOT) for x in fn if x.endswith('.bru') and x != 'folder.bru') print('商机模块 .bru 接口文件总数:', total)