From 421fed8569071d74fc20eb1c678282ced3c7189c 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: Sun, 13 Sep 2026 23:56:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(advisor):=20=E8=A1=A5=E5=85=A8=E6=8A=95?= =?UTF-8?q?=E9=A1=BE=E6=B5=81=E7=A8=8B=E5=85=A5=E5=8F=A3=EF=BC=88=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E7=9B=AE=E6=A0=87=20=E2=86=92=20=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E6=96=B9=E6=A1=88=E4=B9=A6=EF=BC=89=EF=BC=8C=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=A1=B5=E9=9D=A2=E7=BC=BA=E7=BC=93=E5=AD=98=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. 投顾流程此前是断的 投顾工作台只有 4 个「生成草案」操作 + 1 个只读列表,而**确认目标**与**查看方案书** 这两个端点虽然存在却**没有入口** —— 于是目标永远停在 `pending_confirmation`、 方案书永远停在 `pending`。 实测客户 9001 正是如此:`confirmed_at: null`、方案书 `review_status=pending`。 而「录入客户目标」成功后的提示是"已提交,等待确认与审核",**没有下一步可点**。 改动: - 新增操作卡「目标确认与方案书」:输入客户 ID → 查询目标 → 显示目标状态、 方案书状态、收益区间 / 回撤 / 期限 / 基准 / 确认时间 → **确认目标** / **查看方案书**。 - **录入目标后自动跳到目标状态页**,把确认按钮摆在眼前,而不是停在"已提交"。 - `ADVISOR_CONFIRM_GOAL` / `ADVISOR_GOAL_BOOK` 补登记进 `api-client.js` (`ADVISOR_CUSTOMER_GOAL` 早已注册却从未被使用)。 - 404「当前投资目标不存在」按**正常情况**处理(给下一步提示),不当故障报警。 ⚠️ **审核与发布不在此范围**:`review_book` / `publish_book` 都要求 `admin=True` (见 `investment_goal_service`),那是管理员的动作,投顾侧到"确认 + 看方案书"为止。 方案书仍会停在 `pending`,除非管理员侧也补上入口。 ## 2. 9 个页面的 script 标签缺缓存版本参数 投顾工作台的 script 是 `dashboard.js`(**没有 `?v=`**),另有 8 个页面同样如此 (客户的持仓/下单/流水/盈亏/测评/成交明细、管理员工作台、运营工作台)。 浏览器会一直用缓存的旧 JS —— **改了代码也看不到效果**,这是"功能很奇怪"的一部分。 已统一补上 `?v=20260913`,投顾页的 js/css 提到 `-2`。 ## 3. 过程中我损坏过 8 个文件,已恢复 第一次批量补版本参数时我用了 PowerShell `Get-Content -Raw` + `Set-Content`: **PS 5.1 默认按 ANSI/GBK 读**,把 UTF-8 中文读成乱码再写回 (`角色与权限` → `瑙掕壊鏉冮檺`)并写入 BOM。`tests/unit/api/test_portal_frontend.py` 的断言当场抓到了它(找不到「角色与权限」)。 已 `git checkout` 恢复全部 8 个文件,改用 Python 重做(显式 UTF-8、不写 BOM、 保持原换行),现在每个文件的改动都是 **1 增 1 删**。 验证:`tests/unit/api/test_portal_frontend.py` 35 passed;unit+contract **1395 passed**; ruff 通过;mypy 251 文件 0 错;确认目标实测 200 (`pending_confirmation` → `confirmed`,写入 `confirmed_at`), 重复确认被状态机以 409 拒绝。 --- app/static/portal/common/api-client.js | 2 + .../portal/customer/cash-ledger/index.html | 2 +- .../portal/customer/holdings/index.html | 2 +- app/static/portal/customer/orders/index.html | 2 +- .../portal/customer/profit-loss/index.html | 2 +- .../customer/risk-questionnaire/index.html | 2 +- .../portal/customer/transactions/index.html | 2 +- .../employee-advisor/dashboard/dashboard.js | 124 +++++++++++++++++- .../employee-advisor/dashboard/index.html | 4 +- .../employee-console/workspace/index.html | 2 +- .../employee-operations/dashboard/index.html | 2 +- 11 files changed, 135 insertions(+), 11 deletions(-) diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 6bf92ad..2a9f33f 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -60,6 +60,8 @@ const ENDPOINTS = Object.freeze({ ADVISOR_RECOMMEND: { method: 'POST', path: '/api/v1/advisor/recommendations', idempotent: true }, ADVISOR_CREATE_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals', idempotent: true }, ADVISOR_CUSTOMER_GOAL: { method: 'GET', path: '/api/v1/advisor/customers/{customerId}/investment-goals/current' }, + ADVISOR_CONFIRM_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/confirmations', idempotent: true }, + ADVISOR_GOAL_BOOK: { method: 'GET', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book' }, OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' }, OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' }, }); diff --git a/app/static/portal/customer/cash-ledger/index.html b/app/static/portal/customer/cash-ledger/index.html index 33dc45f..bd4bdec 100644 --- a/app/static/portal/customer/cash-ledger/index.html +++ b/app/static/portal/customer/cash-ledger/index.html @@ -1 +1 @@ -资金流水 · 南方财富

资金流水

记录模拟交易引起的扣款、回款与交易后余额变化。

账户资金明细

+资金流水 · 南方财富

资金流水

记录模拟交易引起的扣款、回款与交易后余额变化。

账户资金明细

diff --git a/app/static/portal/customer/holdings/index.html b/app/static/portal/customer/holdings/index.html index a3b80a7..557cc74 100644 --- a/app/static/portal/customer/holdings/index.html +++ b/app/static/portal/customer/holdings/index.html @@ -1 +1 @@ -我的持仓 · 南方财富

我的持仓

持仓份额、市值和盈亏均由 T006 后端接口返回。

持仓明细

+我的持仓 · 南方财富

我的持仓

持仓份额、市值和盈亏均由 T006 后端接口返回。

持仓明细

diff --git a/app/static/portal/customer/orders/index.html b/app/static/portal/customer/orders/index.html index b2f79ea..9bdfd16 100644 --- a/app/static/portal/customer/orders/index.html +++ b/app/static/portal/customer/orders/index.html @@ -1 +1 @@ -交易记录 · 南方财富

交易记录

按提交时间倒序展示模拟委托,首版市价委托会立即全额成交。

委托记录

+交易记录 · 南方财富

交易记录

按提交时间倒序展示模拟委托,首版市价委托会立即全额成交。

委托记录

diff --git a/app/static/portal/customer/profit-loss/index.html b/app/static/portal/customer/profit-loss/index.html index 353cdac..c52170c 100644 --- a/app/static/portal/customer/profit-loss/index.html +++ b/app/static/portal/customer/profit-loss/index.html @@ -1 +1 @@ -收益明细 · 南方财富

收益明细

仅展示后端已计算的累计盈亏、今日盈亏和持仓盈亏,不推导缺失的已实现收益。

+收益明细 · 南方财富

收益明细

仅展示后端已计算的累计盈亏、今日盈亏和持仓盈亏,不推导缺失的已实现收益。

diff --git a/app/static/portal/customer/risk-questionnaire/index.html b/app/static/portal/customer/risk-questionnaire/index.html index b01afc7..9f9b8b3 100644 --- a/app/static/portal/customer/risk-questionnaire/index.html +++ b/app/static/portal/customer/risk-questionnaire/index.html @@ -20,6 +20,6 @@
- + diff --git a/app/static/portal/customer/transactions/index.html b/app/static/portal/customer/transactions/index.html index 0c900cb..c7d7b18 100644 --- a/app/static/portal/customer/transactions/index.html +++ b/app/static/portal/customer/transactions/index.html @@ -1 +1 @@ -成交明细 · 南方财富

成交明细

成交价格、份额、费用与净额均来自 T007 后端记录。

成交记录

+成交明细 · 南方财富

成交明细

成交价格、份额、费用与净额均来自 T007 后端记录。

成交记录

diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.js b/app/static/portal/employee-advisor/dashboard/dashboard.js index 6ec2019..4eeb20c 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.js +++ b/app/static/portal/employee-advisor/dashboard/dashboard.js @@ -11,6 +11,22 @@ const CONTENT_TYPE_LABELS = { advisor_recommendation_plan: '产品推荐方案', }; +//: 目标与方案书的状态口径(`investment_goal_service` 的状态机): +//: 目标 `pending_confirmation` --确认--> `confirmed`; +//: 方案书 `pending` --管理员审核--> `approved` --发布--> `published`。 +//: ⚠️ 审核与发布都要求 `admin=True`(见 `review_book` / `publish_book`), +//: 所以投顾这边到"确认目标 + 查看方案书"为止,剩下两步归管理员。 +const GOAL_STATUS_LABELS = { + pending_confirmation: '待确认', + confirmed: '已确认', +}; +const BOOK_STATUS_LABELS = { + pending: '待审核', + approved: '审核通过', + published: '已发布', + rejected: '已退回', +}; + if (requireAdvisor()) { mountShell({ active: 'advisor-dashboard', mode: 'advisor' }); const context = getAuthContext(); @@ -52,6 +68,94 @@ if (requireAdvisor()) { output.innerHTML = `
`; output.querySelector('[data-goal-form]').addEventListener('submit', submitGoal); } + // ---- 目标确认与方案书:把"录入目标"之后断掉的流程接上 ---- + // + // 此前工作台只有 4 个"生成草案"的操作 + 1 个只读列表,而**确认目标**与 + // **查看方案书**这两个端点虽然存在却没有入口,于是目标永远停在 + // `pending_confirmation`、方案书永远停在 `pending`(实测客户 9001 正是如此)。 + function renderGoalStatusForm() { + output.innerHTML = `

查到目标后可在此确认目标、查看方案书。

`; + output.querySelector('[data-load-goal]').addEventListener('click', () => { + const value = Number(output.querySelector('[data-goal-customer]').value); + if (!value) { showAlert('请填写客户 ID。'); return; } + loadGoalStatus(value); + }); + } + + async function loadGoalStatus(customerId) { + clearAlert(); + output.innerHTML = '
正在查询客户目标…
'; + try { + const response = await apiClient.get('ADVISOR_CUSTOMER_GOAL', { pathParams: { customerId } }); + renderGoalStatus(response.data ?? response); + } catch (error) { + apiClient.reportError(error); + // 404「当前投资目标不存在」是**正常情况**(客户还没录入目标), + // 要给出下一步提示,而不是当成故障报警。 + if (error.status === 404) { + showAlert(`客户 ${customerId} 还没有投资目标,请先用「录入客户目标」创建。`, 'info'); + output.innerHTML = '
该客户暂无投资目标。
'; + return; + } + showAlert(error.message || '查询未完成,请稍后重试。'); + output.innerHTML = '
查询失败,请检查权限或稍后重试。
'; + } + } + + function renderGoalStatus(goal) { + const goalNo = goal.goal_no; + const status = goal.status; + const book = goal.goal_book || {}; + const canConfirm = status === 'pending_confirmation'; + const rows = [ + ['目标编号', goalNo], + ['客户', goal.customer_id], + ['年化收益区间', `${goal.annualized_return_lower_pct}% ~ ${goal.annualized_return_upper_pct}%`], + ['最大回撤', `${goal.max_drawdown_pct}%`], + ['投资期限', `${goal.investment_horizon_months} 个月`], + ['业绩基准', goal.benchmark_name], + ['确认时间', goal.confirmed_at ? formatDateTime(goal.confirmed_at) : '尚未确认'], + ]; + output.innerHTML = `
客户目标${escapeHtml(GOAL_STATUS_LABELS[status] || status || '--')}
${rows.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(String(value ?? '--'))}
`).join('')}
方案书${escapeHtml(BOOK_STATUS_LABELS[book.review_status] || book.review_status || '--')}
${canConfirm ? '' : ''}

${canConfirm ? '确认后目标才可用于资产配置与推荐;方案书需由管理员审核发布。' : '方案书的审核与发布由管理员完成,投顾侧到此为止。'}

`; + output.querySelector('[data-confirm-goal]')?.addEventListener('click', () => confirmGoal(goalNo)); + output.querySelector('[data-view-book]')?.addEventListener('click', () => viewGoalBook(goalNo)); + } + + async function confirmGoal(goalNo) { + clearAlert(); + try { + await apiClient.post('ADVISOR_CONFIRM_GOAL', { confirmed: true }, { pathParams: { goalNo } }); + showAlert('目标已确认。', 'info'); + const customerId = Number(output.querySelector('[data-goal-customer]')?.value || 9001); + await loadGoalStatus(customerId); + } catch (error) { + apiClient.reportError(error); + showAlert(error.message || '确认未完成,请稍后重试。'); + } + } + + async function viewGoalBook(goalNo) { + clearAlert(); + try { + const response = await apiClient.get('ADVISOR_GOAL_BOOK', { pathParams: { goalNo } }); + const data = response.data ?? response; + const sections = data.content?.sections || {}; + const objective = sections.investment_objective || {}; + const expectation = objective.annualized_return_expectation_pct || {}; + const rows = [ + ['业绩基准', objective.benchmark_name], + ['年化收益期望', `${expectation.lower ?? '--'}% ~ ${expectation.upper ?? '--'}%`], + ['最大回撤', sections.risk_boundary?.maximum_drawdown_pct ? `${sections.risk_boundary.maximum_drawdown_pct}%` : null], + ['投资期限', sections.investment_horizon?.months ? `${sections.investment_horizon.months} 个月` : null], + ['流动性', sections.liquidity?.description], + ]; + output.innerHTML = `
投资目标方案书${escapeHtml(BOOK_STATUS_LABELS[data.review_status] || data.review_status || '--')}
${rows.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(String(value ?? '--'))}
`).join('')}
${(data.content?.disclosures || []).map((item) => `

${escapeHtml(item)}

`).join('')}`; + } catch (error) { + apiClient.reportError(error); + showAlert(error.message || '方案书读取失败。'); + } + } + async function submitGoal(event) { event.preventDefault(); clearAlert(); const form = event.currentTarget; const value = (name) => form.elements[name].value.trim(); @@ -59,7 +163,24 @@ if (requireAdvisor()) { if (lower > upper) { showAlert('年化收益下限不能高于上限。'); return; } const body = { annualized_return_lower_pct: value('annualized_return_lower_pct'), annualized_return_upper_pct: value('annualized_return_upper_pct'), max_drawdown_pct: value('max_drawdown_pct'), liquidity_requirement: value('liquidity_requirement'), investment_horizon_months: Number(value('investment_horizon_months')), benchmark_name: value('benchmark_name'), notes: value('notes') || null }; if (value('customer_id')) body.customer_id = Number(value('customer_id')); - await runAction('ADVISOR_CREATE_GOAL', body, '客户目标已提交,等待确认与审核'); + clearAlert(); + output.innerHTML = '
正在保存客户目标…
'; + try { + const response = await apiClient.post('ADVISOR_CREATE_GOAL', body); + const data = response.data ?? response; + // 创建出的目标处于 `pending_confirmation`,**必须确认才能用于后续分析**。 + // 所以这里直接跳到目标状态页,把确认按钮摆在眼前,而不是停在"已提交"。 + if (body.customer_id) { + showAlert('客户目标已保存,请确认目标。', 'info'); + await loadGoalStatus(body.customer_id); + return; + } + renderResult('客户目标已保存,等待确认与审核', data); + } catch (error) { + apiClient.reportError(error); + showAlert(error.message || '保存未完成,请稍后重试。'); + output.innerHTML = '
请求失败,请检查权限或稍后重试。
'; + } } async function runAction(endpoint, body, title) { clearAlert(); output.innerHTML = '
正在请求服务端分析…
'; @@ -69,6 +190,7 @@ if (requireAdvisor()) { function openAction(action) { clearAlert(); if (action === 'goal') { renderGoalForm(); return; } + if (action === 'goal-status') { renderGoalStatusForm(); return; } if (action === 'recommend') { output.innerHTML = '
'; output.querySelector('[data-submit-recommend]').addEventListener('click', () => runAction('ADVISOR_RECOMMEND', { limit: Number(output.querySelector('[data-recommend-limit]').value) }, '推荐方案草案')); diff --git a/app/static/portal/employee-advisor/dashboard/index.html b/app/static/portal/employee-advisor/dashboard/index.html index 77b615d..e143ee5 100644 --- a/app/static/portal/employee-advisor/dashboard/index.html +++ b/app/static/portal/employee-advisor/dashboard/index.html @@ -1,3 +1,3 @@ -投顾工作台 · 南方财富 -

客户洞察与方案协作

投顾工作台

围绕客户目标、组合分析与已发布方案开展合规的投顾协作,所有建议均需经过审核流程。

客户目标组合分析方案发布
投顾人员权限加载中

工作操作

调用已授权的投顾分析能力,结果仅作为方案草案

选择一项操作,结果将在这里展示。

已发布方案

服务端已审核并发布的客户方案

工作边界

建议生成与审核留痕

  • 先读画像与目标仅访问分配范围内客户,不展示未授权信息。
  • 再做组合分析分析结果是工作草案,不代替人工判断。
  • 最后提交审核发布前保留审核、版本与审计记录。
+投顾工作台 · 南方财富 +

客户洞察与方案协作

投顾工作台

围绕客户目标、组合分析与已发布方案开展合规的投顾协作,所有建议均需经过审核流程。

客户目标组合分析方案发布
投顾人员权限加载中

工作操作

调用已授权的投顾分析能力,结果仅作为方案草案

选择一项操作,结果将在这里展示。

已发布方案

服务端已审核并发布的客户方案

工作边界

建议生成与审核留痕

  • 先读画像与目标仅访问分配范围内客户,不展示未授权信息。
  • 再做组合分析分析结果是工作草案,不代替人工判断。
  • 最后提交审核发布前保留审核、版本与审计记录。
diff --git a/app/static/portal/employee-console/workspace/index.html b/app/static/portal/employee-console/workspace/index.html index d3de985..d8749ea 100644 --- a/app/static/portal/employee-console/workspace/index.html +++ b/app/static/portal/employee-console/workspace/index.html @@ -39,6 +39,6 @@

详情

确认操作

- + diff --git a/app/static/portal/employee-operations/dashboard/index.html b/app/static/portal/employee-operations/dashboard/index.html index 07ce254..d216abb 100644 --- a/app/static/portal/employee-operations/dashboard/index.html +++ b/app/static/portal/employee-operations/dashboard/index.html @@ -1,3 +1,3 @@ 运营工作台 · 南方财富 -

场外运营与资料处理

运营工作台

集中查看收件箱、识别任务与规则状态,所有场外运营流程与场内模拟交易数据严格隔离。

邮件收件识别队列规则核对
运营人员权限加载中

场外基金收件箱

仅展示未删除的运营邮件

运行状态

收件游标与识别监控

+

场外运营与资料处理

运营工作台

集中查看收件箱、识别任务与规则状态,所有场外运营流程与场内模拟交易数据严格隔离。

邮件收件识别队列规则核对
运营人员权限加载中

场外基金收件箱

仅展示未删除的运营邮件

运行状态

收件游标与识别监控