diff --git a/app/api/controllers/onboarding.py b/app/api/controllers/onboarding.py index 32ae473..8ac734a 100644 --- a/app/api/controllers/onboarding.py +++ b/app/api/controllers/onboarding.py @@ -1,6 +1,6 @@ """Mandatory customer onboarding endpoints.""" -from fastapi import APIRouter, Depends, Header, status +from fastapi import APIRouter, Depends, Header, Query, status from app.api.dependencies.auth import build_request_context from app.api.schemas.risk_questionnaire import RiskQuestionnaireSubmission @@ -12,9 +12,10 @@ router = APIRouter(prefix="/api/v1/onboarding", tags=["customer-onboarding"]) @router.get("/risk-questionnaire") async def get_risk_questionnaire( + retake: bool = Query(default=False), context: RequestContext = Depends(build_request_context), # noqa: B008 ) -> dict[str, object]: - return await RiskQuestionnaireService().questionnaire(context) + return await RiskQuestionnaireService().questionnaire(context, retake=retake) @router.post("/risk-questionnaire/submissions", status_code=status.HTTP_201_CREATED) diff --git a/app/service/risk_questionnaire_service.py b/app/service/risk_questionnaire_service.py index e0c212c..8fc8650 100644 --- a/app/service/risk_questionnaire_service.py +++ b/app/service/risk_questionnaire_service.py @@ -61,17 +61,35 @@ class _ScoreResult: class RiskQuestionnaireService: - async def questionnaire(self, context: RequestContext) -> dict[str, object]: + async def questionnaire( + self, context: RequestContext, *, retake: bool = False + ) -> dict[str, object]: + """Return the current questionnaire definition for a customer. + + A completed assessment remains the default read state so onboarding guards + continue to behave exactly as before. The explicit ``retake`` mode lets a + customer request the same server-owned questions again without weakening + submission frequency limits or exposing scoring rules. + """ self._require_customer(context) required = await self.is_required(context) + pending_review = False + if not required: + async with SessionFactory() as session: + pending_review = ( + await RiskQuestionnaireRepository(session).pending_drift_review( + int(context.user_id) + ) + ) is not None return { "data": { "required": required, + "review_status": "pending_review" if pending_review else None, "questionnaire": { "version": QUESTIONNAIRE_VERSION, "questions": QUESTIONS, "declaration": DECLARATION, - } if required else None, + } if required or retake else None, }, "meta": {"trace_id": context.trace_id}, } @@ -122,6 +140,7 @@ class RiskQuestionnaireService: active_tags = await repository.active_tags(customer_id, lock=True) candidates = self._profile_tag_candidates(score, payload.answers, assessment_id) changes = self._drift_changes(active_tags, candidates) + review_pending = bool(active_tags and changes) audit_action: str audit_detail: dict[str, object] if active_tags and changes: @@ -199,6 +218,7 @@ class RiskQuestionnaireService: return { "data": { "completed": True, + "status": "pending_review" if review_pending else "active", "questionnaire_version": QUESTIONNAIRE_VERSION, "valid_until": valid_until.isoformat() + "Z", }, diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 505a705..d6c8497 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -16,6 +16,8 @@ const ENDPOINTS = Object.freeze({ A039: { method: 'GET', path: '/api/v1/admin/customer-profile-candidates' }, A040: { method: 'POST', path: '/api/v1/admin/customer-profile-candidates/{candidateId}/reviews' }, A001: { method: 'POST', path: '/api/v1/admin/config-releases', idempotent: true }, + A041: { method: 'GET', path: '/api/v1/admin/advisor/profile-drift-reviews' }, + A042: { method: 'POST', path: '/api/v1/admin/advisor/profile-drift-reviews/{reviewId}/reviews', idempotent: true }, A002: { method: 'GET', path: '/api/v1/admin/config-releases' }, A008: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items', idempotent: true }, A009: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items' }, diff --git a/app/static/portal/customer/risk-questionnaire/index.html b/app/static/portal/customer/risk-questionnaire/index.html index 9f9b8b3..1c22939 100644 --- a/app/static/portal/customer/risk-questionnaire/index.html +++ b/app/static/portal/customer/risk-questionnaire/index.html @@ -5,7 +5,7 @@ 风险承受能力测评 · 南方财富 - +
@@ -20,6 +20,6 @@
- + diff --git a/app/static/portal/customer/risk-questionnaire/risk-questionnaire.css b/app/static/portal/customer/risk-questionnaire/risk-questionnaire.css index 6a6bc80..39322b5 100644 --- a/app/static/portal/customer/risk-questionnaire/risk-questionnaire.css +++ b/app/static/portal/customer/risk-questionnaire/risk-questionnaire.css @@ -23,6 +23,9 @@ .questionnaire-declaration input { width: 18px; height: 18px; margin-top: 3px; flex: 0 0 auto; accent-color: var(--brand); } .questionnaire-complete { min-height: 420px; padding: var(--space-7); display: grid; place-items: center; text-align: center; } .questionnaire-complete__mark { width: 56px; height: 56px; margin: 0 auto var(--space-4); display: grid; place-items: center; color: var(--surface); background: var(--brand); border-radius: 50%; font-size: 26px; } +.questionnaire-complete__mark--pending { background: #a8792d; } .questionnaire-complete h2 { margin: 0; font-size: 26px; } .questionnaire-complete p { max-width: 520px; margin: var(--space-3) auto var(--space-5); color: var(--muted); line-height: 1.75; } +.questionnaire-complete__hint { margin-top: calc(var(--space-3) * -1) !important; color: var(--ink-soft) !important; font-size: var(--fs-small); } +.questionnaire-complete__actions { display: flex; justify-content: center; flex-wrap: wrap; gap: var(--space-3); } @media (max-width: 700px) { .questionnaire-heading { align-items: flex-start; flex-direction: column; } .questionnaire-heading__status { justify-items: start; } .questionnaire-options { grid-template-columns: 1fr; } .questionnaire-content { padding: var(--space-4); } .questionnaire-actions .button { flex: 1; } } diff --git a/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js b/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js index 0f63a7f..b276d62 100644 --- a/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js +++ b/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js @@ -21,7 +21,17 @@ if (requireCustomerOnly()) { function renderComplete(validUntil) { updateProgress(true); - root.innerHTML = `

风险测评已完成

${validUntil ? `本次测评有效至 ${escapeHtml(formatDateTime(validUntil))}。` : '当前测评仍在有效期内。'}您现在可以访问资产、持仓与模拟交易服务。

进入资产总览
`; + root.innerHTML = `

风险测评已完成

${validUntil ? `本次测评有效至 ${escapeHtml(formatDateTime(validUntil))}。` : '当前测评仍在有效期内。'}您现在可以访问资产、持仓与模拟交易服务。

如个人情况发生变化,可重新完成测评。每日最多提交 2 次,年度最多提交 8 次。

进入资产总览
`; + root.querySelector('[data-retake]')?.addEventListener('click', () => { + state.answers = {}; + state.index = 0; + load({ retake: true }); + }); + } + + function renderPendingReview(validUntil) { + updateProgress(true); + root.innerHTML = `

您的测评情况已提交,正在审核

${validUntil ? `本次测评有效至 ${escapeHtml(formatDateTime(validUntil))}。` : '新的风险画像正在由管理员复核。'}审核完成后,新的测评结果才会用于产品适当性判断。

`; } function renderQuestion() { @@ -66,8 +76,13 @@ if (requireCustomerOnly()) { submit.textContent = '正在提交'; try { const response = await apiClient.post('ONB002', { answers: state.answers, declaration_accepted: true }); - showToast('风险测评提交成功'); - renderComplete(response.data?.valid_until); + if (response.data?.status === 'pending_review') { + showToast('测评情况已提交,正在审核'); + renderPendingReview(response.data?.valid_until); + } else { + showToast('风险测评提交成功'); + renderComplete(response.data?.valid_until); + } } catch (error) { apiClient.reportError(error); alert.textContent = error.message || '测评提交失败,请稍后重试。'; @@ -77,13 +92,28 @@ if (requireCustomerOnly()) { } } - async function load() { + async function load({ retake = false } = {}) { state.controller?.abort(); state.controller = new AbortController(); + state.questions = []; renderLoading(root, 3); try { - const response = await apiClient.get('ONB001', { signal: state.controller.signal }); + const response = await apiClient.get('ONB001', { + signal: state.controller.signal, + query: retake ? { retake: true } : undefined, + }); + if (response.data?.review_status === 'pending_review') { + renderPendingReview(response.data?.valid_until); + return; + } if (!response.data?.required) { + if (retake && Array.isArray(response.data?.questionnaire?.questions)) { + const questionnaire = response.data.questionnaire; + state.questions = questionnaire.questions; + state.declaration = questionnaire.declaration || ''; + renderQuestion(); + return; + } renderComplete(response.data?.valid_until); return; } diff --git a/app/static/portal/employee-advisor/dashboard/actions-module.js b/app/static/portal/employee-advisor/dashboard/actions-module.js new file mode 100644 index 0000000..cb94b53 --- /dev/null +++ b/app/static/portal/employee-advisor/dashboard/actions-module.js @@ -0,0 +1,85 @@ +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { escapeHtml } from '/static/portal/common/formatters.js'; +import { ACTION_LABELS, RESULT_MESSAGES } from './advisor-config.js'; + +export function createActionsModule({ output, alert }) { + function showAlert(message, kind = 'error') { + alert.textContent = message; + alert.className = `form-alert form-alert--visible${kind === 'info' ? ' form-alert--info' : ''}`; + } + + function clearAlert() { + alert.textContent = ''; + alert.className = 'form-alert'; + } + + function renderResult(title, data) { + const status = data?.status; + const message = RESULT_MESSAGES[status]; + output.innerHTML = `
${escapeHtml(title)}${escapeHtml(status || 'ready')}
${message ? `

${escapeHtml(message)}

` : `
${escapeHtml(JSON.stringify(data, null, 2))}
`}`; + } + + async function run(endpoint, body, title) { + clearAlert(); + output.innerHTML = '
正在请求服务端分析…
'; + try { + const response = await apiClient.post(endpoint, body); + renderResult(title, response.data ?? response); + } catch (error) { + apiClient.reportError(error); + showAlert(error.message || '请求未完成,请稍后重试。'); + output.innerHTML = '
请求失败,请检查权限或稍后重试。
'; + } + } + + function renderGoalForm() { + output.innerHTML = `
`; + output.querySelector('[data-goal-form]').addEventListener('submit', submitGoal); + } + + async function submitGoal(event) { + event.preventDefault(); + clearAlert(); + const form = event.currentTarget; + const value = (name) => form.elements[name].value.trim(); + const lower = Number(value('annualized_return_lower_pct')); + const upper = Number(value('annualized_return_upper_pct')); + 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 run('ADVISOR_CREATE_GOAL', body, '客户目标已提交,等待确认与审核'); + } + + function open(action) { + clearAlert(); + if (action === 'goal') { + renderGoalForm(); + return; + } + if (action === 'recommend') { + output.innerHTML = '
'; + output.querySelector('[data-submit-recommend]').addEventListener('click', () => run('ADVISOR_RECOMMEND', { limit: Number(output.querySelector('[data-recommend-limit]').value) }, '推荐方案草案')); + return; + } + run(action === 'portfolio' ? 'ADVISOR_ANALYSIS' : 'ADVISOR_ALLOCATION', {}, ACTION_LABELS[action]); + } + + function bind() { + document.querySelectorAll('[data-action]').forEach((button) => { + button.addEventListener('click', () => open(button.dataset.action)); + }); + } + + return Object.freeze({ bind }); +} diff --git a/app/static/portal/employee-advisor/dashboard/advisor-config.js b/app/static/portal/employee-advisor/dashboard/advisor-config.js new file mode 100644 index 0000000..bc588e2 --- /dev/null +++ b/app/static/portal/employee-advisor/dashboard/advisor-config.js @@ -0,0 +1,19 @@ +export const CONTENT_TYPE_LABELS = Object.freeze({ + investment_goal_book: '投资目标方案书', + advisor_recommendation_plan: '产品推荐方案', +}); + +export const ACTION_LABELS = Object.freeze({ + portfolio: '组合分析', + allocation: '资产配置', + recommend: '生成推荐草案', + goal: '录入客户目标', +}); + +export const RESULT_MESSAGES = Object.freeze({ + profile_required: '客户尚未完成风险测评,请先完成测评后再分析。', + investment_goal_required: '暂无已确认投资目标,暂不能生成配置或推荐。', + investment_goal_invalid: '投资目标数据不完整,请检查客户目标。', + no_positions: '当前客户暂无可分析的持仓。', + valuation_required: '持仓缺少可用市值,暂不能计算集中度。', +}); diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.js b/app/static/portal/employee-advisor/dashboard/dashboard.js index 4eeb20c..4bc7773 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.js +++ b/app/static/portal/employee-advisor/dashboard/dashboard.js @@ -4,13 +4,6 @@ import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js' import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js'; -//: 后端 `published` 返回的是"我可见的已发布交付物",用 `content_type` 区分两类。 -//: 没有标签的话投顾只看到"方案 1",不知道是哪个客户、哪一类内容。 -const CONTENT_TYPE_LABELS = { - investment_goal_book: '投资目标方案书', - advisor_recommendation_plan: '产品推荐方案', -}; - //: 目标与方案书的状态口径(`investment_goal_service` 的状态机): //: 目标 `pending_confirmation` --确认--> `confirmed`; //: 方案书 `pending` --管理员审核--> `approved` --发布--> `published`。 diff --git a/app/static/portal/employee-advisor/dashboard/index.html b/app/static/portal/employee-advisor/dashboard/index.html index e143ee5..f49c27a 100644 --- a/app/static/portal/employee-advisor/dashboard/index.html +++ b/app/static/portal/employee-advisor/dashboard/index.html @@ -1,3 +1,47 @@ -投顾工作台 · 南方财富 -

客户洞察与方案协作

投顾工作台

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

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

工作操作

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

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

已发布方案

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

工作边界

建议生成与审核留痕

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

客户洞察与方案协作

+

投顾工作台

+

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

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

工作操作

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

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

已发布方案

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

+
+
+
+

工作边界

建议生成与审核留痕
  • 先读画像与目标仅访问分配范围内客户,不展示未授权信息。
  • 再做组合分析分析结果是工作草案,不代替人工判断。
  • 最后提交审核发布前保留审核、版本与审计记录。
+
+ + + diff --git a/app/static/portal/employee-advisor/dashboard/published-module.js b/app/static/portal/employee-advisor/dashboard/published-module.js new file mode 100644 index 0000000..e412536 --- /dev/null +++ b/app/static/portal/employee-advisor/dashboard/published-module.js @@ -0,0 +1,31 @@ +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; +import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js'; +import { CONTENT_TYPE_LABELS } from './advisor-config.js'; + +export function createPublishedModule({ list, metrics }) { + async function load() { + renderLoading(list, 3); + try { + const response = await apiClient.get('ADVISOR_PUBLISHED'); + const rows = Array.isArray(response.data) ? response.data : []; + metrics.innerHTML = [ + ['已发布方案', rows.length, '服务端返回'], + ['协作客户', '按归属', '数据范围内'], + ['审核状态', '留痕', '发布前需复核'], + ].map(([label, value, meta]) => + `

${escapeHtml(label)}

${escapeHtml(value)}

${escapeHtml(meta)}

` + ).join(''); + if (!rows.length) { + renderEmpty(list, '暂未发布方案', '当前账号暂无已审核发布的客户方案。'); + return; + } + list.innerHTML = rows.map((row) => `

${escapeHtml(CONTENT_TYPE_LABELS[row.content_type] || '方案')} · 客户 ${escapeHtml(row.customer_id || '--')}

发布时间:${escapeHtml(formatDateTime(row.published_at))}

${escapeHtml(typeof row.plan === 'string' ? row.plan : JSON.stringify(row.plan || {}))}
`).join(''); + } catch (error) { + apiClient.reportError(error); + renderError(list, error, load); + } + } + + return Object.freeze({ load }); +} diff --git a/app/static/portal/employee-console/workspace/index.html b/app/static/portal/employee-console/workspace/index.html index 5385c18..7f0f5b9 100644 --- a/app/static/portal/employee-console/workspace/index.html +++ b/app/static/portal/employee-console/workspace/index.html @@ -23,6 +23,7 @@ +
@@ -39,10 +40,12 @@ +

详情

确认操作

+ diff --git a/app/static/portal/employee-console/workspace/workspace.js b/app/static/portal/employee-console/workspace/workspace.js index b51afb1..178a503 100644 --- a/app/static/portal/employee-console/workspace/workspace.js +++ b/app/static/portal/employee-console/workspace/workspace.js @@ -1,4 +1,4 @@ -import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260914'; import { getAuthContext, requireAdmin, updateAuthPermissions } from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; @@ -24,7 +24,7 @@ function table(items, columns, action) { if (requireAdmin()) { mountShell({ active: 'admin-workspace', mode: 'admin' }); const context = getAuthContext(); - const state = { roles: [], releases: [], endpoints: [], audits: [], handovers: [], candidates: [], advisor: [], knowledge: [], releaseContent: null, action: null }; + const state = { roles: [], releases: [], endpoints: [], audits: [], handovers: [], candidates: [], advisor: [], knowledge: [], driftReviews: [], releaseContent: null, action: null }; const targets = { roles: document.querySelector('[data-role-table]'), releases: document.querySelector('[data-release-table]'), @@ -35,6 +35,7 @@ if (requireAdmin()) { candidates: document.querySelector('[data-candidate-table]'), advisor: document.querySelector('[data-advisor-table]'), knowledge: document.querySelector('[data-knowledge-table]'), + drift: document.querySelector('[data-drift-table]'), }; const detailDialog = document.querySelector('[data-admin-detail]'); const actionDialog = document.querySelector('[data-admin-action]'); @@ -53,7 +54,7 @@ if (requireAdmin()) { ['有效角色', state.roles.filter((item) => item.status === 'active').length, 'RBAC 角色定义'], ['生效配置', activeRelease, `共 ${state.releases.length} 个发布版本`], ['可用模型端点', activeEndpoints, `已登记 ${state.endpoints.length} 个端点`], - ['待人工复核', openHandovers + state.candidates.length, `工单 ${openHandovers} · 画像候选 ${state.candidates.length}`], + ['待人工复核', openHandovers + state.candidates.length + state.driftReviews.length, `工单 ${openHandovers} · 画像候选 ${state.candidates.length} · 漂移 ${state.driftReviews.length}`], ]; document.querySelector('[data-admin-metrics]').innerHTML = metrics.map(([label, count, meta]) => `

${label}

${count}

${escapeHtml(meta)}

`).join(''); } @@ -536,6 +537,22 @@ if (requireAdmin()) { apiClient.reportError(error); status.textContent = error.message || '提交失败'; } finally { submit.disabled = false; } + async function loadDriftReviews() { + renderLoading(targets.drift, 3); + try { + const response = await apiClient.get('A041'); + state.driftReviews = Array.isArray(response.data) ? response.data : []; + if (!state.driftReviews.length) { + renderEmpty(targets.drift, '暂无画像漂移复核', '当前没有因客户重新测评产生的待复核画像。'); + } else { + targets.drift.innerHTML = table( + state.driftReviews, + [['review_id', '复核 ID'], ['customer_id', '客户 ID'], ['candidate_profile_version', '候选版本'], ['status', '状态'], ['created_at', '创建时间']], + (item) => `
`, + ); + targets.drift.querySelectorAll('[data-drift-action]').forEach((button) => button.addEventListener('click', () => openDriftAction(button.dataset.driftAction, button.dataset.driftId))); + } + } catch (error) { apiClient.reportError(error); renderError(targets.drift, error, loadDriftReviews); } } function openReleaseAction(action, releaseId) { @@ -554,6 +571,17 @@ if (requireAdmin()) { openActionDialog(approved ? '批准画像候选' : '驳回画像候选', approved ? '批准后候选将进入正式记忆,并处理同字段旧值。' : '驳回后候选不会进入正式记忆。', true); } + function openDriftAction(decision, reviewId) { + const item = state.driftReviews.find((row) => String(row.review_id) === String(reviewId)); + const approved = decision === 'approved'; + state.action = { type: 'drift', reviewId, decision }; + openActionDialog( + approved ? '批准画像漂移' : '驳回画像漂移', + approved ? `批准客户 ${item?.customer_id || '--'} 的新画像,后续投顾分析将使用新测评结果。` : `驳回客户 ${item?.customer_id || '--'} 的新画像,继续保留原有画像。`, + true, + ); + } + function openActionDialog(title, copy, showComment) { document.querySelector('[data-admin-action-title]').textContent = title; document.querySelector('[data-admin-action-copy]').textContent = copy; @@ -599,6 +627,10 @@ if (requireAdmin()) { await apiClient.post('A040', { decision: state.action.decision, comment }, { pathParams: { candidateId: state.action.candidateId } }); showToast(state.action.decision === 'approved' ? '画像候选已批准' : '画像候选已驳回'); await loadCandidates(); + } else if (state.action.type === 'drift') { + await apiClient.post('A042', { decision: state.action.decision, comment }, { pathParams: { reviewId: state.action.reviewId } }); + showToast(state.action.decision === 'approved' ? '画像漂移已批准' : '画像漂移已驳回'); + await Promise.all([loadDriftReviews(), loadAudits()]); } else { const detail = await apiClient.get('A003', { pathParams: { releaseId: state.action.releaseId } }); const body = { ...state.action.body, ...(state.action.endpoint === 'A005' ? { comment } : {}) }; @@ -625,6 +657,7 @@ if (requireAdmin()) { document.querySelector('[data-reload-knowledge]').addEventListener('click', loadKnowledge); document.querySelector('[data-knowledge-form]').addEventListener('submit', submitKnowledge); document.querySelector('[data-release-form]').addEventListener('submit', submitRelease); + document.querySelector('[data-reload-drift]').addEventListener('click', loadDriftReviews); document.querySelector('[data-admin-action-form]').addEventListener('submit', submitAdminAction); document.querySelectorAll('[data-close-detail]').forEach((button) => button.addEventListener('click', () => detailDialog.close())); document.querySelectorAll('[data-close-admin-action]').forEach((button) => button.addEventListener('click', () => actionDialog.close())); @@ -632,7 +665,7 @@ if (requireAdmin()) { async function initialize() { document.querySelector('[data-admin-metrics]').innerHTML = Array.from({ length: 4 }, () => '
').join(''); try { await hydrateIdentity(); } catch (error) { apiClient.reportError(error); showToast(error.message || '权限加载失败', 'error'); } - await Promise.all([loadRoles(), loadReleases(), loadEndpoints(), loadAudits(), loadHandovers(), loadCandidates(), loadAdvisorReviews(), loadKnowledge()]); + await Promise.all([loadRoles(), loadReleases(), loadEndpoints(), loadAudits(), loadHandovers(), loadCandidates(), loadAdvisorReviews(), loadKnowledge(), loadDriftReviews()]); renderMetrics(); } diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index c356142..1b80898 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -115,6 +115,18 @@ def test_advisor_workspace_registers_documented_operation_endpoints() -> None: assert label in dashboard +def test_advisor_dashboard_is_composed_from_feature_modules() -> None: + source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text( + encoding="utf-8" + ) + assert "./actions-module.js" in source + assert "./published-module.js" in source + config = (PORTAL / "employee-advisor" / "dashboard" / "advisor-config.js").read_text( + encoding="utf-8" + ) + assert "ACTION_LABELS" in config + + def test_risk_scan_endpoint_uses_extended_timeout() -> None: source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8") assert ( diff --git a/tests/unit/service/test_risk_questionnaire_service.py b/tests/unit/service/test_risk_questionnaire_service.py index 7f0ba50..2a4be2b 100644 --- a/tests/unit/service/test_risk_questionnaire_service.py +++ b/tests/unit/service/test_risk_questionnaire_service.py @@ -114,6 +114,49 @@ def test_score_bands_are_deterministic_and_server_only() -> None: assert (high.total, high.risk_level) == (57, "C5") +@pytest.mark.asyncio +async def test_completed_questionnaire_can_be_requested_for_retake( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class SessionContext: + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, *_args: object) -> None: + return None + + async def not_required(_self: object, _context: RequestContext) -> bool: + return False + + async def no_pending_review( + _self: object, _customer_id: int, *, lock: bool = False + ) -> None: + del lock + return None + + monkeypatch.setattr( + RiskQuestionnaireService, "is_required", not_required + ) + monkeypatch.setattr( + "app.service.risk_questionnaire_service.SessionFactory", + lambda: SessionContext(), + ) + monkeypatch.setattr( + "app.service.risk_questionnaire_service.RiskQuestionnaireRepository.pending_drift_review", + no_pending_review, + ) + service = RiskQuestionnaireService() + + regular = await service.questionnaire(context()) + assert regular["data"]["questionnaire"] is None # type: ignore[index] + + retake = await service.questionnaire(context(), retake=True) + questionnaire = retake["data"]["questionnaire"] # type: ignore[index] + assert isinstance(questionnaire, dict) + assert questionnaire["version"] == "opening-risk-v1" + assert len(questionnaire["questions"]) == 13 + + @pytest.mark.asyncio async def test_submission_persists_assessment_and_profile_without_exposing_them( monkeypatch: pytest.MonkeyPatch, @@ -139,8 +182,9 @@ async def test_submission_persists_assessment_and_profile_without_exposing_them( ) data = cast(dict[str, object], response["data"]) - assert set(data) == {"completed", "questionnaire_version", "valid_until"} + assert set(data) == {"completed", "status", "questionnaire_version", "valid_until"} assert data["completed"] is True + assert data["status"] == "active" assert not {"total_score", "risk_level", "risk_profile", "profile"}.intersection(data) assert any(isinstance(item, RiskAssessment) for item in session.items) assert any(isinstance(item, ProfileSnapshot) for item in session.items)