From 2525fbdad9b3d3fc798cd1bfb8daec7e0660016b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Sat, 12 Sep 2026 15:45:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal):=20=E6=8E=A8=E5=B9=BF=E6=9D=90?= =?UTF-8?q?=E6=96=99=E8=A1=A5=E6=88=90=E5=AE=8C=E6=95=B4=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E5=90=91=E5=AF=BC=EF=BC=8C=E5=B9=B6=E6=94=B9=E4=B8=BA=E6=8C=89?= =?UTF-8?q?=20body.code=20=E5=88=A4=E6=88=90=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 投顾视图:创建 -> 补结构化输入(含七项费率) -> 生成 -> 投递 -> 查询 生成后自动把 material_version_id 填进投递框 - 管理员视图:新增推广材料审核(promotion:review 只给管理员,职责分离) - 统一结果渲染改看 body.code:本平台业务失败也返回 HTTP 200 (生成失败是 HTTP 200 + code=422 + '材料内容未通过合规校验') - 输入骨架刻意不预填业绩(performance_info 有 show_* 开关可关), 费率填占位文本以便跑通流程;真实材料必须换成真实值 --- tools/portal.py | 225 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 206 insertions(+), 19 deletions(-) diff --git a/tools/portal.py b/tools/portal.py index 422d689..3006b54 100644 --- a/tools/portal.py +++ b/tools/portal.py @@ -939,6 +939,26 @@ function renderAdmin(box) {

知识库

未加载
+ +
+

推广材料审核

+

审核需要 promotion:review,只给管理员 —— 投顾没有这个码(职责分离), + 所以"生成"之后必须由管理员在这里通过,投顾才能投递。
+ 版本号从哪来:投顾执行「生成」后,响应里的 material_version_id 就是它 + (状态 pending_review)。注意本平台失败时也返回 HTTP 200, + 判断成败要看 body.code。

+
+ + +
+
+ + + + + +
+
`; loadRoles(); loadAudit(); @@ -1021,6 +1041,31 @@ async function loadKnowledge() { : `
HTTP ${r.status}
${esc(pretty(r.body))}
`; } +async function admPromoLookup() { + const t = $('promo-task').value.trim(); + if (!t) return alert('请填任务单号'); + const r = await GET(`/api/v1/fund-promotion-materials/${t}`); + const mv = ((r.body || {}).data || {}).material_version || {}; + if (mv.id) $('adm-version').value = mv.id; + $('promo-admin-extra').innerHTML = `

任务 ${esc(t)}

+

HTTP ${r.status}(管理员不受"已投递"限制,能直接看到版本)

+
${esc(pretty(r.body))}
`; +} + +async function admReview(decision) { + const t = $('promo-task').value.trim(); + const vid = parseInt($('adm-version').value.trim(), 10); + if (!t) return alert('请填任务单号'); + if (!vid) return alert('请填 material_version_id(可先点「查任务」自动填)'); + const comment = $('adm-comment').value.trim() || null; + if (!confirm(`对 ${t} 的版本 ${vid} 执行「${decision}」?`)) return; + const r = await jpost('/api/call', { method:'POST', + path:`/api/v1/fund-promotion-materials/${t}/reviews`, + body:{ material_version_id: vid, decision, comment } }); + $('promo-admin-extra').innerHTML = `

审核结果(${esc(decision)})

+

HTTP ${r.status}

${esc(pretty(r.body))}
`; +} + /* ---------------- 投顾 ---------------- */ function renderAdvisor(box) { box.innerHTML = ` @@ -1044,22 +1089,83 @@ function renderAdvisor(box) {
-

推广材料

+

推广材料(按流程走)

这条线此前因为 promotion:* 四个权限码在库里根本不存在而整体 403,现已补齐。 - 创建任务需要产品名、材料标题与风格代码。

+ 顺序不能跳:创建 → 补结构化输入 → 生成 → 管理员审核 → 投递 → 才能查询。 + 跳过任何一步,查询都会返回「该材料尚未发送给当前投顾」—— 那是合规设计,不是故障。

- - - - + + +
- - + + + + +
+
+ + +
+
+ + + 审核在「权限管理」页用管理员做(投顾没有 promotion:review —— 职责分离) +
+
+ + +
`; goal(); + promoSkeleton(); +} + +function promoSkeleton() { + // 两个刻意的选择: + // 1) 不预填任何业绩数字 —— `performance_info` 的业绩字段全部可选且带 + // `show_product_performance` 开关,关掉即可;编造业绩是合规红线。 + // 2) 费率七项**必须非空**:合规规则 `fee_structure.incomplete`(severity=block) + // 会拦下生成,任务会被置成 `compliance_failed`。这里填占位文本让它先跑通流程, + // 真实材料必须换成真实费率 —— 页面上也这么提示。 + $('promo-inputs').value = JSON.stringify({ + product_info: { + fund_type: "混合型", + operation_mode: "契约型开放式", + investment_objective: "在严格控制风险的前提下,力争实现基金资产的长期稳健增值", + benchmark: "沪深300指数收益率×60%+中债综合指数收益率×40%", + risk_level: "R3" + }, + manager_info: { + management_company: "南方科技基金管理有限公司", + manager_name: "待填写", + registration_code: "待填写" + }, + team_info: { team_description: "待填写" }, + strategy_info: { + investment_scope: "本基金投资于国内依法发行上市的股票、债券等金融工具", + strategy: "自上而下的大类资产配置与自下而上的个股精选相结合", + restrictions: "不投资于法律法规禁止的标的" + }, + fee_structure: { + subscription_fee: "待填写", purchase_fee: "待填写", redemption_fee: "待填写", + sales_service_fee: "待填写", management_fee: "待填写", custody_fee: "待填写", + client_maintenance_fee: "待填写" + }, + performance_info: { show_product_performance: false, show_manager_performance: false }, + risk_disclosure: { special_risks: ["市场风险", "流动性风险"], additional_notes: "详见基金合同与招募说明书" }, + source_notes: {} + }, null, 2); +} + +function promoTaskNo() { + const t = $('promo-task').value.trim(); + if (!t) { alert('请先填任务单号(创建任务后会返回)'); return null; } + return t; } async function promoCreate() { @@ -1067,16 +1173,75 @@ async function promoCreate() { const title = $('promo-title').value.trim(); const style = $('promo-style').value.trim(); if (!name || !title || !style) return alert('产品名、材料标题、风格代码都要填'); - if (!confirm(`创建推广材料任务?\n产品:${name}\n标题:${title}\n风格:${style}`)) return; - showAdv('创建推广材料', await jpost('/api/call', { - method:'POST', path:'/api/v1/fund-promotion-materials', - body:{ product_name: name, material_title: title, style_code: style } })); + const r = await jpost('/api/call', { method:'POST', path:'/api/v1/fund-promotion-materials', + body:{ product_name: name, material_title: title, style_code: style } }); + const d = (r.body || {}).data || {}; + if (d.task_no) $('promo-task').value = d.task_no; + showAdv('① 创建任务' + (d.task_no ? ':' + d.task_no : ''), r); +} + +async function promoSaveInputs() { + const t = promoTaskNo(); if (!t) return; + let body; + try { body = JSON.parse($('promo-inputs').value); } + catch (e) { return alert('结构化输入不是合法 JSON:' + e.message); } + const r = await jpost('/api/call', { method:'PUT', + path:`/api/v1/fund-promotion-materials/${t}/inputs`, body }); + showAdv('② 保存结构化输入 ' + t, r); +} + +async function promoGenerate(fmt) { + const t = promoTaskNo(); if (!t) return; + if (!confirm(`生成 ${fmt.toUpperCase()}?这一步会产出材料版本。`)) return; + const r = await jpost('/api/call', { method:'POST', + path:`/api/v1/fund-promotion-materials/${t}/generations`, body:{ output_formats:[fmt] } }); + const body = r.body || {}; + const d = body.data || {}; + if (d.material_version_id) $('promo-version').value = d.material_version_id; + showAdv('③ 生成 ' + fmt.toUpperCase() + ' ' + t, r); + if (d.material_version_id) { + $('advisor-extra').insertAdjacentHTML('beforeend', + `

已产出 material_version_id=${d.material_version_id}, + 状态 pending_review(所以此刻查询任务看不到它 —— get_task 只返回 approved/sent)。 + 下一步请管理员在「权限管理」页对它审核通过,之后你才能投递;版本号已自动填进上面的投递框。

`); + } else if (body.code) { + $('advisor-extra').insertAdjacentHTML('beforeend', + `

生成未成功(body.code=${esc(body.code)})。最常见的原因是 + 费率七项没填全:合规规则 fee_structure.incomplete 会把任务置成 + compliance_failed。另注意:这个平台失败时也返回 HTTP 200,要看 body.code。

`); + } +} + +async function promoDeliver() { + const t = promoTaskNo(); if (!t) return; + const versionId = parseInt($('promo-version').value.trim(), 10); + if (!versionId) return alert('请填 material_version_id(先做管理员审核,审核通过后从任务详情里拿)'); + const ids = $('promo-advisors').value.split(',').map((x) => parseInt(x.trim(), 10)).filter((x) => x); + if (!ids.length) return alert('请填要投递的投顾 id'); + if (!confirm(`把版本 ${versionId} 投递给投顾 ${ids.join(', ')}?`)) return; + const r = await jpost('/api/call', { method:'POST', + path:`/api/v1/fund-promotion-materials/${t}/deliveries`, + body:{ material_version_id: versionId, advisor_ids: ids, delivery_channel:'internal_record' } }); + showAdv('⑤ 投递 ' + t, r); } async function promoGet() { - const no = $('promo-no').value.trim(); - if (!no) return alert('请先填单号'); - showAdv('推广材料 ' + no, await GET(`/api/v1/fund-promotion-materials/${no}`)); + const t = promoTaskNo(); if (!t) return; + const r = await GET(`/api/v1/fund-promotion-materials/${t}`); + const err = ((r.body || {}).error) || {}; + let extra = ''; + if (r.status === 404 && String(err.message || '').includes('尚未发送')) { + extra = ' —— 材料存在但还没投递给你:请确认已完成「补输入 → 生成 → 管理员审核 → 投递」。' + + '版本号可以在下面的原始返回里找,或让管理员查这个任务。'; + } + showAdv('查询任务 ' + t, r); + if (extra) $('advisor-extra').insertAdjacentHTML('beforeend', `

${esc(extra)}

`); +} + +async function promoChecks() { + const t = promoTaskNo(); if (!t) return; + showAdv('合规检查 ' + t, + await GET(`/api/v1/fund-promotion-materials/${t}/compliance-checks`)); } async function portfolioAnalysis() { @@ -1092,10 +1257,32 @@ async function assetAllocation() { } function showAdv(title, r) { - $('advisor-extra').innerHTML = `

${esc(title)}

-

HTTP ${r.status}${r.status === 403 ? ' —— 权限不足' : ''} - ${r.status === 422 ? ' —— 报文格式不对(看下面 error 的字段提示)' : ''}

-
${esc(pretty(r.body))}
`; + $('advisor-extra').innerHTML = result(title, r); +} + +// 统一的结果渲染:**必须看 body.code**,不能只看 HTTP 状态。 +// 这个平台把业务失败也放在 HTTP 200 里(例如生成材料失败是 +// `HTTP 200 + body.code=422 + message=材料内容未通过合规校验`), +// 只看 HTTP 会把它显示成成功。 +function result(title, r) { + const body = (r && r.body) || {}; + const code = body.code; + const err = body.error || {}; + const biz = (code === undefined || code === null) ? null : Number(code); + const bad = (r.status >= 400) || (biz !== null && biz !== 0) || !!err.code; + const bits = [`HTTP ${r.status}`]; + if (biz !== null) bits.push(`body.code ${biz}`); + if (err.code) bits.push(`error ${esc(err.code)}`); + let extra = ''; + if (err.message) extra = esc(err.message); + else if (bad && body.message) extra = esc(body.message); + if (!bad) { + if (r.status === 403) extra = '当前角色权限不足(平台按设计 fail closed)'; + else if (r.status === 404) extra = '资源不存在或不可见(见下方 message)'; + } + return `

${esc(title)}

+

${bits.join(' · ')}${extra ? ' —— ' + extra : ''}

+
${esc(pretty(body))}
`; } async function goal() {