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) {
`;
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() {