feat(portal): 推广材料补成完整流程向导,并改为按 body.code 判成败
- 投顾视图:创建 -> 补结构化输入(含七项费率) -> 生成 -> 投递 -> 查询 生成后自动把 material_version_id 填进投递框 - 管理员视图:新增推广材料审核(promotion:review 只给管理员,职责分离) - 统一结果渲染改看 body.code:本平台业务失败也返回 HTTP 200 (生成失败是 HTTP 200 + code=422 + '材料内容未通过合规校验') - 输入骨架刻意不预填业绩(performance_info 有 show_* 开关可关), 费率填占位文本以便跑通流程;真实材料必须换成真实值
This commit is contained in:
+206
-19
@@ -939,6 +939,26 @@ function renderAdmin(box) {
|
|||||||
<h2>知识库</h2>
|
<h2>知识库</h2>
|
||||||
<div class="row"><button class="act" onclick="loadKnowledge()">列出知识文档</button></div>
|
<div class="row"><button class="act" onclick="loadKnowledge()">列出知识文档</button></div>
|
||||||
<div id="knowledge"><div class="muted">未加载</div></div>
|
<div id="knowledge"><div class="muted">未加载</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>推广材料审核</h2>
|
||||||
|
<p class="hint">审核需要 <code>promotion:review</code>,只给管理员 —— 投顾没有这个码(职责分离),
|
||||||
|
所以"生成"之后必须由管理员在这里通过,投顾才能投递。<br>
|
||||||
|
<b>版本号从哪来</b>:投顾执行「生成」后,响应里的 <code>material_version_id</code> 就是它
|
||||||
|
(状态 <code>pending_review</code>)。注意本平台<b>失败时也返回 HTTP 200</b>,
|
||||||
|
判断成败要看 <code>body.code</code>。</p>
|
||||||
|
<div class="row">
|
||||||
|
<input class="q" id="promo-task" placeholder="任务单号 PM-..." style="width:220px">
|
||||||
|
<button class="act" onclick="admPromoLookup()">查任务(自动填版本号)</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<input class="q" id="adm-version" placeholder="material_version_id" style="width:150px">
|
||||||
|
<input class="q" id="adm-comment" placeholder="审核意见(可选)" style="width:240px">
|
||||||
|
<button class="act primary" onclick="admReview('approved')">通过</button>
|
||||||
|
<button class="act warn" onclick="admReview('revision_requested')">退回修改</button>
|
||||||
|
<button class="act warn" onclick="admReview('rejected')">拒绝</button>
|
||||||
|
</div>
|
||||||
|
<div id="promo-admin-extra"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
loadRoles();
|
loadRoles();
|
||||||
loadAudit();
|
loadAudit();
|
||||||
@@ -1021,6 +1041,31 @@ async function loadKnowledge() {
|
|||||||
: `<div class="muted">HTTP ${r.status}</div><pre>${esc(pretty(r.body))}</pre>`;
|
: `<div class="muted">HTTP ${r.status}</div><pre>${esc(pretty(r.body))}</pre>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = `<h2>任务 ${esc(t)}</h2>
|
||||||
|
<p class="hint">HTTP <b>${r.status}</b>(管理员不受"已投递"限制,能直接看到版本)</p>
|
||||||
|
<pre>${esc(pretty(r.body))}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<h2>审核结果(${esc(decision)})</h2>
|
||||||
|
<p class="hint">HTTP <b>${r.status}</b></p><pre>${esc(pretty(r.body))}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 投顾 ---------------- */
|
/* ---------------- 投顾 ---------------- */
|
||||||
function renderAdvisor(box) {
|
function renderAdvisor(box) {
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
@@ -1044,22 +1089,83 @@ function renderAdvisor(box) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>推广材料</h2>
|
<h2>推广材料(按流程走)</h2>
|
||||||
<p class="hint">这条线此前因为 <code>promotion:*</code> 四个权限码在库里根本不存在而整体 403,现已补齐。
|
<p class="hint">这条线此前因为 <code>promotion:*</code> 四个权限码在库里根本不存在而整体 403,现已补齐。
|
||||||
创建任务需要产品名、材料标题与风格代码。</p>
|
<b>顺序不能跳</b>:创建 → <b>补结构化输入</b> → 生成 → <b>管理员审核</b> → 投递 → 才能查询。
|
||||||
|
跳过任何一步,查询都会返回「该材料尚未发送给当前投顾」—— 那是合规设计,不是故障。</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input class="q" id="promo-name" placeholder="产品名称" value="南方科技稳健增利">
|
<input class="q" id="promo-task" placeholder="任务单号 PM-..." style="width:220px">
|
||||||
<input class="q" id="promo-title" placeholder="材料标题" value="稳健增利推介材料">
|
<button class="act" onclick="promoGet()">查询任务</button>
|
||||||
<input class="q" id="promo-style" placeholder="风格代码" value="steady_professional" style="width:170px">
|
<button class="act" onclick="promoChecks()">合规检查结果</button>
|
||||||
<button class="act primary" onclick="promoCreate()">创建推广材料</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input class="q" id="promo-no" placeholder="按单号查询,例如 PM-...">
|
<input class="q" id="promo-name" value="南方科技稳健增利" placeholder="产品名称">
|
||||||
<button class="act" onclick="promoGet()">查询</button>
|
<input class="q" id="promo-title" value="稳健增利推介材料" placeholder="材料标题">
|
||||||
|
<input class="q" id="promo-style" value="steady_professional" placeholder="风格代码" style="width:160px">
|
||||||
|
<button class="act primary" onclick="promoCreate()">① 创建任务</button>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>② 结构化输入(PUT inputs,六个块都必填;下面是骨架,把「待填写」补上再提交)
|
||||||
|
<button class="act" onclick="promoSaveInputs()">保存输入</button></label>
|
||||||
|
<textarea id="promo-inputs"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="act" onclick="promoGenerate('pptx')">③ 生成 PPTX</button>
|
||||||
|
<button class="act" onclick="promoGenerate('pdf')">③ 生成 PDF</button>
|
||||||
|
<span class="muted">审核在「权限管理」页用管理员做(投顾没有 promotion:review —— 职责分离)</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<input class="q" id="promo-version" placeholder="material_version_id" style="width:150px">
|
||||||
|
<input class="q" id="promo-advisors" value="9020" placeholder="投递给哪些投顾(逗号分隔 id)" style="width:260px">
|
||||||
|
<button class="act primary" onclick="promoDeliver()">⑤ 投递</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel" id="advisor-extra"></div>`;
|
<div class="panel" id="advisor-extra"></div>`;
|
||||||
goal();
|
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() {
|
async function promoCreate() {
|
||||||
@@ -1067,16 +1173,75 @@ async function promoCreate() {
|
|||||||
const title = $('promo-title').value.trim();
|
const title = $('promo-title').value.trim();
|
||||||
const style = $('promo-style').value.trim();
|
const style = $('promo-style').value.trim();
|
||||||
if (!name || !title || !style) return alert('产品名、材料标题、风格代码都要填');
|
if (!name || !title || !style) return alert('产品名、材料标题、风格代码都要填');
|
||||||
if (!confirm(`创建推广材料任务?\n产品:${name}\n标题:${title}\n风格:${style}`)) return;
|
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/fund-promotion-materials',
|
||||||
showAdv('创建推广材料', await jpost('/api/call', {
|
body:{ product_name: name, material_title: title, style_code: style } });
|
||||||
method:'POST', path:'/api/v1/fund-promotion-materials',
|
const d = (r.body || {}).data || {};
|
||||||
body:{ product_name: name, material_title: title, style_code: style } }));
|
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',
|
||||||
|
`<p class="hint">已产出 <code>material_version_id=${d.material_version_id}</code>,
|
||||||
|
状态 <b>pending_review</b>(所以此刻查询任务看不到它 —— get_task 只返回 approved/sent)。
|
||||||
|
下一步请<b>管理员</b>在「权限管理」页对它审核通过,之后你才能投递;版本号已自动填进上面的投递框。</p>`);
|
||||||
|
} else if (body.code) {
|
||||||
|
$('advisor-extra').insertAdjacentHTML('beforeend',
|
||||||
|
`<p class="hint bad">生成未成功(body.code=${esc(body.code)})。最常见的原因是
|
||||||
|
<b>费率七项没填全</b>:合规规则 <code>fee_structure.incomplete</code> 会把任务置成
|
||||||
|
compliance_failed。另注意:这个平台失败时也返回 HTTP 200,要看 body.code。</p>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
async function promoGet() {
|
||||||
const no = $('promo-no').value.trim();
|
const t = promoTaskNo(); if (!t) return;
|
||||||
if (!no) return alert('请先填单号');
|
const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
|
||||||
showAdv('推广材料 ' + no, await GET(`/api/v1/fund-promotion-materials/${no}`));
|
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', `<p class="hint">${esc(extra)}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promoChecks() {
|
||||||
|
const t = promoTaskNo(); if (!t) return;
|
||||||
|
showAdv('合规检查 ' + t,
|
||||||
|
await GET(`/api/v1/fund-promotion-materials/${t}/compliance-checks`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function portfolioAnalysis() {
|
async function portfolioAnalysis() {
|
||||||
@@ -1092,10 +1257,32 @@ async function assetAllocation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showAdv(title, r) {
|
function showAdv(title, r) {
|
||||||
$('advisor-extra').innerHTML = `<h2>${esc(title)}</h2>
|
$('advisor-extra').innerHTML = result(title, r);
|
||||||
<p class="hint">HTTP <b>${r.status}</b>${r.status === 403 ? ' —— 权限不足' : ''}
|
}
|
||||||
${r.status === 422 ? ' —— 报文格式不对(看下面 error 的字段提示)' : ''}</p>
|
|
||||||
<pre>${esc(pretty(r.body))}</pre>`;
|
// 统一的结果渲染:**必须看 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 <b>${r.status}</b>`];
|
||||||
|
if (biz !== null) bits.push(`body.code <b>${biz}</b>`);
|
||||||
|
if (err.code) bits.push(`error <b>${esc(err.code)}</b>`);
|
||||||
|
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 `<h2>${esc(title)}</h2>
|
||||||
|
<p class="hint ${bad ? 'bad' : ''}">${bits.join(' · ')}${extra ? ' —— ' + extra : ''}</p>
|
||||||
|
<pre>${esc(pretty(body))}</pre>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goal() {
|
async function goal() {
|
||||||
|
|||||||
Reference in New Issue
Block a user